diff --git a/CommunitySupport/CMSCloudKitRepresentable.swift b/CommunitySupport/CMSCloudKitRepresentable.swift index 512fb83..865e1fd 100644 --- a/CommunitySupport/CMSCloudKitRepresentable.swift +++ b/CommunitySupport/CMSCloudKitRepresentable.swift @@ -12,6 +12,7 @@ import CloudKit public enum CMSCloudKitError: Error { case missingKey(String) case invalidData(String) + case notFound } public protocol CMSCloudKitRepresentable { diff --git a/CommunitySupport/CMSCommunityCenter.swift b/CommunitySupport/CMSCommunityCenter.swift index e3a79b0..d43b270 100644 --- a/CommunitySupport/CMSCommunityCenter.swift +++ b/CommunitySupport/CMSCommunityCenter.swift @@ -8,12 +8,22 @@ import Cocoa import CloudKit +import RxSwift public enum CMSResult { case success(T) case error(Error) } +public enum CMSCloudAccountStatus { + case available + case unavailable +} + +extension Notification.Name { + static let CMSUserProfileDidChange = Notification.Name("CMSUserProfileDidChangeNotificationName") +} + public final class CMSCommunityCenter: NSObject { private lazy var container: CKContainer = CKContainer.default() @@ -27,22 +37,33 @@ public final class CMSCommunityCenter: NSObject { public typealias CMSProgressBlock = (_ progress: Double) -> Void public typealias CMSCompletionBlock = (_ error: Error?) -> Void + public lazy var accountStatus: Observable = { + return self.createAccountStatusObservable() + }() + + public lazy var userProfile: Observable = { + return self.createUserProfileObservable() + }() + public override init() { super.init() NotificationCenter.default.addObserver(self, selector: #selector(createSubscriptionsIfNeeded), name: .NSApplicationDidFinishLaunching, object: nil) } - public func save(model: CMSCloudKitRepresentable, progress: @escaping CMSProgressBlock, completion: @escaping CMSCompletionBlock) { + public func save(model: CMSCloudKitRepresentable, progress: CMSProgressBlock?, completion: @escaping CMSCompletionBlock) { do { let record = try model.makeRecord() let operation = CKModifyRecordsOperation(recordsToSave: [record], recordIDsToDelete: nil) + operation.savePolicy = .changedKeys - operation.perRecordProgressBlock = { progressRecord, currentProgress in - guard progressRecord == record else { return } - - DispatchQueue.main.async { progress(currentProgress) } + if let progress = progress { + operation.perRecordProgressBlock = { progressRecord, currentProgress in + guard progressRecord == record else { return } + + DispatchQueue.main.async { progress(currentProgress) } + } } operation.modifyRecordsCompletionBlock = { _, _, error in @@ -61,6 +82,45 @@ public final class CMSCommunityCenter: NSObject { } } + public func profileForUser(withNickname nickname: String, completion: @escaping CMSUserCompletionBlock) { + let predicate = NSPredicate(format: "nickname == %@", nickname) + let query = CKQuery(recordType: CMSUserProfile.recordType, predicate: predicate) + + let operation = CKQueryOperation(query: query) + + var records: [CKRecord] = [] + + operation.recordFetchedBlock = { record in + records.append(record) + } + + operation.queryCompletionBlock = { _, error in + let retryBlock = { self.profileForUser(withNickname: nickname, completion: completion) } + + if let error = retryCloudKitOperationIfPossible(with: error, block: retryBlock) { + let effectiveError = CMSCloudKitError.invalidData(error.localizedDescription) + DispatchQueue.main.async { completion(.error(effectiveError)) } + return + } + + guard let record = records.first else { + let error = CMSCloudKitError.notFound + DispatchQueue.main.async { completion(.error(error)) } + return + } + + do { + let profile = try CMSUserProfile(record: record) + + DispatchQueue.main.async { completion(.success(profile)) } + } catch { + DispatchQueue.main.async { completion(.error(error)) } + } + } + + database.add(operation) + } + public typealias CMSUserCompletionBlock = (_ result: CMSResult) -> Void public func fetchCurrentUserProfile(_ completion: @escaping CMSUserCompletionBlock) { @@ -99,6 +159,64 @@ public final class CMSCommunityCenter: NSObject { } } + public func promptAndUpdateUserProfileWithDiscoveredInfo(with profile: CMSUserProfile, completion: @escaping (CMSUserProfile?, Error?) -> Void) { + container.requestApplicationPermission(.userDiscoverability) { status, error in + if let error = error { + DispatchQueue.main.async { completion(nil, CMSCloudKitError.invalidData("Unable to request permission:\n\(error.localizedDescription)")) } + return + } + + switch status { + case .granted: + self.fetchUserInfo(for: profile, completion: completion) + default: break + } + } + } + + private func fetchUserInfo(for profile: CMSUserProfile, completion: @escaping (CMSUserProfile?, Error?) -> Void) { + guard let record = profile.originatingRecord else { + DispatchQueue.main.async { completion(nil, CMSCloudKitError.invalidData("Invalid profile: no originating record")) } + return + } + + container.discoverUserIdentity(withUserRecordID: record.recordID) { identity, error in + guard let identity = identity, error == nil else { + DispatchQueue.main.async { completion(nil, CMSCloudKitError.invalidData("Unable to get user identity")) } + return + } + + guard let nameComponents = identity.nameComponents else { + DispatchQueue.main.async { completion(nil, CMSCloudKitError.invalidData("Unable to parse user name")) } + return + } + + let formatter = PersonNameComponentsFormatter() + let fullName = formatter.string(from: nameComponents) + + self.updateUser(record: record, withName: fullName, completion: completion) + } + } + + private func updateUser(record: CKRecord, withName fullName: String, completion: @escaping (CMSUserProfile?, Error?) -> Void) { + do { + var newProfile = try CMSUserProfile(record: record) + newProfile.name = fullName + + self.save(model: newProfile, progress: nil) { error in + if let error = error { + DispatchQueue.main.async { completion(nil, CMSCloudKitError.invalidData("Unable to save profile: \(error.localizedDescription)")) } + } else { + DispatchQueue.main.async { completion(newProfile, nil) } + } + } + } catch { + DispatchQueue.main.async { completion(nil, CMSCloudKitError.invalidData("Unable to save profile: \(error.localizedDescription)")) } + } + } + + // MARK: - Subscriptions + public func processNotification(userInfo: [String : Any]) -> Bool { // TODO: process CloudKit notification return false @@ -108,4 +226,54 @@ public final class CMSCommunityCenter: NSObject { // TODO: create subscriptions for relevant record types } + // MARK: - Observable generators + + private func createAccountStatusObservable() -> Observable { + return Observable.create { observer -> Disposable in + let checkAccountStatus = { + self.container.accountStatus { status, error in + guard error == nil else { + slog("Error checking CloudKit account status: \(error?.localizedDescription ?? "Unknown")") + observer.onNext(.unavailable) + return + } + + switch status { + case .available: + observer.onNext(.available) + default: + observer.onNext(.unavailable) + } + + NotificationCenter.default.post(name: .CMSUserProfileDidChange, object: nil) + } + } + + let cloudKitObserver = NotificationCenter.default.addObserver(forName: .CKAccountChanged, object: nil, queue: nil) { _ in + checkAccountStatus() + } + + checkAccountStatus() + + return Disposables.create { NotificationCenter.default.removeObserver(cloudKitObserver) } + } + } + + private func createUserProfileObservable() -> Observable { + return Observable.create { observer -> Disposable in + let profileNotificationObserver = NotificationCenter.default.addObserver(forName: .CMSUserProfileDidChange, object: nil, queue: nil) { _ in + self.fetchCurrentUserProfile({ result in + switch result { + case .success(let profile): + observer.onNext(profile) + case .error(let error): + observer.onError(error) + } + }) + } + + return Disposables.create { NotificationCenter.default.removeObserver(profileNotificationObserver) } + } + } + } diff --git a/CommunitySupport/CMSUserProfile.swift b/CommunitySupport/CMSUserProfile.swift index 81096ed..9cff60e 100644 --- a/CommunitySupport/CMSUserProfile.swift +++ b/CommunitySupport/CMSUserProfile.swift @@ -11,24 +11,44 @@ import CloudKit public struct CMSUserProfile { - public static let recordType = "User" + public static let recordType = "Users" public var originatingRecord: CKRecord? - internal var avatarFileURL: URL? + public var avatarFileURL: URL? public let identifier: String public var name: String - public let nickname: String public var avatar: NSImage? - public var site: URL? public let isAdmin: Bool + public static var empty: CMSUserProfile { + return CMSUserProfile(originatingRecord: nil, + avatarFileURL: nil, + identifier: "", + name: "", + avatar: nil, + isAdmin: false) + } + +} + +extension CMSUserProfile: Equatable { + + var isEmpty: Bool { + return identifier.isEmpty + } + + public static func ==(lhs: CMSUserProfile, rhs: CMSUserProfile) -> Bool { + return lhs.identifier == rhs.identifier + && lhs.avatarFileURL == rhs.avatarFileURL + && lhs.name == rhs.name + && lhs.isAdmin == rhs.isAdmin + } + } enum CMSUserProfileKey: String { case name - case nickname - case url case isAdmin case avatar } @@ -49,17 +69,7 @@ extension CKRecord { extension CMSUserProfile: CMSCloudKitRepresentable { public init(record: CKRecord) throws { - guard let name = record[.name] as? String else { - throw CMSCloudKitError.missingKey(CMSUserProfileKey.name.rawValue) - } - - guard let nickname = record[.nickname] as? String else { - throw CMSCloudKitError.missingKey(CMSUserProfileKey.nickname.rawValue) - } - - guard let url = record[.url] as? String else { - throw CMSCloudKitError.missingKey(CMSUserProfileKey.url.rawValue) - } + let name = record[.name] as? String ?? "" guard let avatar = record[.avatar] as? CKAsset else { throw CMSCloudKitError.missingKey(CMSUserProfileKey.avatar.rawValue) @@ -69,25 +79,20 @@ extension CMSUserProfile: CMSCloudKitRepresentable { throw CMSCloudKitError.missingKey(CMSUserProfileKey.isAdmin.rawValue) } + self.originatingRecord = record self.identifier = record.recordID.recordName self.name = name - self.nickname = nickname - self.site = URL(string: url) self.isAdmin = (isAdmin == 1) self.avatarFileURL = avatar.fileURL self.avatar = NSImage(contentsOf: avatar.fileURL) } public func makeRecord() throws -> CKRecord { - guard let recordID = self.originatingRecord?.recordID else { - throw CMSCloudKitError.invalidData("User record must have a preexisting record ID") + guard let record = self.originatingRecord else { + throw CMSCloudKitError.invalidData("User record must have a preexisting record") } - let record = CKRecord(recordType: CMSUserProfile.recordType, recordID: recordID) - record[.name] = name - record[.nickname] = nickname - record[.url] = site?.absoluteString record[.isAdmin] = isAdmin ? 1 : 0 if let avatarUrl = self.avatarFileURL, avatarUrl.isFileURL { diff --git a/Design/Theme/WWDC-Artwork/Controls/Progress Indicator Circular/ProgressIndicatorCircular_Determinate_Regular.psd b/Design/Theme/WWDC-Artwork/Controls/Progress Indicator Circular/ProgressIndicatorCircular_Determinate_Regular.psd index 415b334..647e2e6 100755 Binary files a/Design/Theme/WWDC-Artwork/Controls/Progress Indicator Circular/ProgressIndicatorCircular_Determinate_Regular.psd and b/Design/Theme/WWDC-Artwork/Controls/Progress Indicator Circular/ProgressIndicatorCircular_Determinate_Regular.psd differ diff --git a/Design/Theme/WWDC-Artwork/Controls/Progress Indicator Circular/ProgressIndicatorCircular_Determinate_Regular@2x.psd b/Design/Theme/WWDC-Artwork/Controls/Progress Indicator Circular/ProgressIndicatorCircular_Determinate_Regular@2x.psd index 8774fc7..8dc928c 100755 Binary files a/Design/Theme/WWDC-Artwork/Controls/Progress Indicator Circular/ProgressIndicatorCircular_Determinate_Regular@2x.psd and b/Design/Theme/WWDC-Artwork/Controls/Progress Indicator Circular/ProgressIndicatorCircular_Determinate_Regular@2x.psd differ diff --git a/Design/Theme/WWDC-Artwork/Controls/Progress Indicator Circular/ProgressIndicatorCircular_Determinate_Small.psd b/Design/Theme/WWDC-Artwork/Controls/Progress Indicator Circular/ProgressIndicatorCircular_Determinate_Small.psd index f8f60ad..37eb7f3 100755 Binary files a/Design/Theme/WWDC-Artwork/Controls/Progress Indicator Circular/ProgressIndicatorCircular_Determinate_Small.psd and b/Design/Theme/WWDC-Artwork/Controls/Progress Indicator Circular/ProgressIndicatorCircular_Determinate_Small.psd differ diff --git a/Design/Theme/WWDC-Artwork/Controls/Progress Indicator Circular/ProgressIndicatorCircular_Determinate_Small@2x.psd b/Design/Theme/WWDC-Artwork/Controls/Progress Indicator Circular/ProgressIndicatorCircular_Determinate_Small@2x.psd index d6bb83d..e98e128 100755 Binary files a/Design/Theme/WWDC-Artwork/Controls/Progress Indicator Circular/ProgressIndicatorCircular_Determinate_Small@2x.psd and b/Design/Theme/WWDC-Artwork/Controls/Progress Indicator Circular/ProgressIndicatorCircular_Determinate_Small@2x.psd differ diff --git a/Design/Theme/WWDC-Artwork/Controls/Progress Indicator Circular/ProgressIndicatorCircular_Indeterminate_Regular.psd b/Design/Theme/WWDC-Artwork/Controls/Progress Indicator Circular/ProgressIndicatorCircular_Indeterminate_Regular.psd new file mode 100755 index 0000000..cc9ebd5 Binary files /dev/null and b/Design/Theme/WWDC-Artwork/Controls/Progress Indicator Circular/ProgressIndicatorCircular_Indeterminate_Regular.psd differ diff --git a/Design/Theme/WWDC-Artwork/Controls/Progress Indicator Circular/ProgressIndicatorCircular_Indeterminate_Regular@2x.psd b/Design/Theme/WWDC-Artwork/Controls/Progress Indicator Circular/ProgressIndicatorCircular_Indeterminate_Regular@2x.psd new file mode 100755 index 0000000..2d088b2 Binary files /dev/null and b/Design/Theme/WWDC-Artwork/Controls/Progress Indicator Circular/ProgressIndicatorCircular_Indeterminate_Regular@2x.psd differ diff --git a/Design/Theme/WWDC-Artwork/Controls/Progress Indicator Circular/ProgressIndicatorCircular_Indeterminate_Small.psd b/Design/Theme/WWDC-Artwork/Controls/Progress Indicator Circular/ProgressIndicatorCircular_Indeterminate_Small.psd new file mode 100755 index 0000000..70a54dd Binary files /dev/null and b/Design/Theme/WWDC-Artwork/Controls/Progress Indicator Circular/ProgressIndicatorCircular_Indeterminate_Small.psd differ diff --git a/Design/Theme/WWDC-Artwork/Controls/Progress Indicator Circular/ProgressIndicatorCircular_Indeterminate_Small@2x.psd b/Design/Theme/WWDC-Artwork/Controls/Progress Indicator Circular/ProgressIndicatorCircular_Indeterminate_Small@2x.psd new file mode 100755 index 0000000..cc9ebd5 Binary files /dev/null and b/Design/Theme/WWDC-Artwork/Controls/Progress Indicator Circular/ProgressIndicatorCircular_Indeterminate_Small@2x.psd differ diff --git a/Design/Theme/WWDC.car b/Design/Theme/WWDC.car index c7b3892..f7eae86 100644 Binary files a/Design/Theme/WWDC.car and b/Design/Theme/WWDC.car differ diff --git a/Design/Theme/WWDC.tdd b/Design/Theme/WWDC.tdd index dc97207..1767777 100644 Binary files a/Design/Theme/WWDC.tdd and b/Design/Theme/WWDC.tdd differ diff --git a/Design/WWDC Resources.sketch b/Design/WWDC Resources.sketch index ea92acb..159f7f1 100644 Binary files a/Design/WWDC Resources.sketch and b/Design/WWDC Resources.sketch differ diff --git a/WWDC.xcodeproj/project.pbxproj b/WWDC.xcodeproj/project.pbxproj index 64b37d6..836072d 100644 --- a/WWDC.xcodeproj/project.pbxproj +++ b/WWDC.xcodeproj/project.pbxproj @@ -11,6 +11,10 @@ DD0159A91ED09F5D00F980F1 /* AppCoordinator+Bookmarks.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0159A81ED09F5D00F980F1 /* AppCoordinator+Bookmarks.swift */; }; DD0159CF1ED0CD3A00F980F1 /* PreferencesWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0159CE1ED0CD3A00F980F1 /* PreferencesWindowController.swift */; }; DD0159D11ED0CEF500F980F1 /* PreferencesCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0159D01ED0CEF500F980F1 /* PreferencesCoordinator.swift */; }; + DD0159D31ED0D3AF00F980F1 /* AccountPreferencesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0159D21ED0D3AF00F980F1 /* AccountPreferencesViewController.swift */; }; + DD0159D61ED0D98F00F980F1 /* ITSwitch.m in Sources */ = {isa = PBXBuildFile; fileRef = DD0159D51ED0D98F00F980F1 /* ITSwitch.m */; }; + DD0159D71ED0DDF700F980F1 /* RxSwift.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DD5F51831E47DE7E0017F9EC /* RxSwift.framework */; }; + DD0159D91ED11A9800F980F1 /* ModalLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0159D81ED11A9800F980F1 /* ModalLoadingView.swift */; }; DD0353491E5545B300D5E343 /* SessionInstancesJSONAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0353481E5545B300D5E343 /* SessionInstancesJSONAdapter.swift */; }; DD0F0C561E7B8DD600B52184 /* Storage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0F0C551E7B8DD600B52184 /* Storage.swift */; }; DD0F0C571E7B95EC00B52184 /* RxRealm.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DD5F51821E47DE7E0017F9EC /* RxRealm.framework */; }; @@ -291,6 +295,10 @@ DD0159A81ED09F5D00F980F1 /* AppCoordinator+Bookmarks.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "AppCoordinator+Bookmarks.swift"; sourceTree = ""; }; DD0159CE1ED0CD3A00F980F1 /* PreferencesWindowController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PreferencesWindowController.swift; sourceTree = ""; }; DD0159D01ED0CEF500F980F1 /* PreferencesCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PreferencesCoordinator.swift; sourceTree = ""; }; + DD0159D21ED0D3AF00F980F1 /* AccountPreferencesViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountPreferencesViewController.swift; sourceTree = ""; }; + DD0159D41ED0D98F00F980F1 /* ITSwitch.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ITSwitch.h; sourceTree = ""; }; + DD0159D51ED0D98F00F980F1 /* ITSwitch.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ITSwitch.m; sourceTree = ""; }; + DD0159D81ED11A9800F980F1 /* ModalLoadingView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ModalLoadingView.swift; sourceTree = ""; }; DD0353481E5545B300D5E343 /* SessionInstancesJSONAdapter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SessionInstancesJSONAdapter.swift; sourceTree = ""; }; DD0F0C551E7B8DD600B52184 /* Storage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Storage.swift; sourceTree = ""; }; DD2E27871EAC2CCB0009D7B6 /* ShelfView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShelfView.swift; sourceTree = ""; }; @@ -542,6 +550,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + DD0159D71ED0DDF700F980F1 /* RxSwift.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -553,6 +562,7 @@ children = ( DD0159D01ED0CEF500F980F1 /* PreferencesCoordinator.swift */, DD0159CE1ED0CD3A00F980F1 /* PreferencesWindowController.swift */, + DD0159D21ED0D3AF00F980F1 /* AccountPreferencesViewController.swift */, ); name = Preferences; sourceTree = ""; @@ -647,6 +657,8 @@ DD36A4C01E478CF500B2EA88 /* Views */ = { isa = PBXGroup; children = ( + DD0159D41ED0D98F00F980F1 /* ITSwitch.h */, + DD0159D51ED0D98F00F980F1 /* ITSwitch.m */, DDF32EB11EBE34E10028E39D /* Base */, DDF32EB01EBE34CE0028E39D /* TableView */, DD2E27871EAC2CCB0009D7B6 /* ShelfView.swift */, @@ -658,6 +670,7 @@ DDB352991EC8AB2800254815 /* WWDCImageView.swift */, DDAE001C1EC534BF0036C7E9 /* TrackColorView.swift */, DD4648461ECA5947005C57C6 /* WWDCWindow.swift */, + DD0159D81ED11A9800F980F1 /* ModalLoadingView.swift */, ); name = Views; sourceTree = ""; @@ -1502,6 +1515,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + DD0159D61ED0D98F00F980F1 /* ITSwitch.m in Sources */, DDB28F8E1EAD257B0077703F /* PlaybackViewModel.swift in Sources */, DD4648491ECA5EC0005C57C6 /* NSToolbarItemViewer+Overrides.m in Sources */, DDF32EAB1EBE2E240028E39D /* WWDCTableRowView.swift in Sources */, @@ -1515,8 +1529,10 @@ DDB28F861EAD20A10077703F /* UIDebugger.m in Sources */, DDB3529A1EC8AB2800254815 /* WWDCImageView.swift in Sources */, DD0159CF1ED0CD3A00F980F1 /* PreferencesWindowController.swift in Sources */, + DD0159D31ED0D3AF00F980F1 /* AccountPreferencesViewController.swift in Sources */, DD876D351EC2A7410058EE3B /* ImageDownloadCenter.swift in Sources */, DDAE001D1EC534BF0036C7E9 /* TrackColorView.swift in Sources */, + DD0159D91ED11A9800F980F1 /* ModalLoadingView.swift in Sources */, DDF32EB31EBE5C4D0028E39D /* SessionActionsViewController.swift in Sources */, DD7F38761EABFB20002D8C00 /* NSColor+Hex.swift in Sources */, DDB28F931EAD48D70077703F /* UserActivityRepresentable.swift in Sources */, @@ -2479,6 +2495,10 @@ DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Carthage/Build/Mac", + ); FRAMEWORK_VERSION = A; INFOPLIST_FILE = CommunitySupport/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -2507,6 +2527,10 @@ DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Carthage/Build/Mac", + ); FRAMEWORK_VERSION = A; INFOPLIST_FILE = CommunitySupport/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -2535,6 +2559,10 @@ DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Carthage/Build/Mac", + ); FRAMEWORK_VERSION = A; INFOPLIST_FILE = CommunitySupport/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -2562,6 +2590,10 @@ DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Carthage/Build/Mac", + ); FRAMEWORK_VERSION = A; INFOPLIST_FILE = CommunitySupport/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; diff --git a/WWDC/AccountPreferencesViewController.swift b/WWDC/AccountPreferencesViewController.swift new file mode 100644 index 0000000..37c0844 --- /dev/null +++ b/WWDC/AccountPreferencesViewController.swift @@ -0,0 +1,188 @@ +// +// AccountPreferencesViewController.swift +// WWDC +// +// Created by Guilherme Rambo on 20/05/17. +// Copyright © 2017 Guilherme Rambo. All rights reserved. +// + +import Cocoa +import RxSwift +import RxCocoa +import CommunitySupport + +class AccountPreferencesViewController: NSViewController { + + private let disposeBag = DisposeBag() + + var profile: CMSUserProfile? { + didSet { + updateUI() + } + } + + var cloudAccountIsAvailable = false { + didSet { + updateUI() + } + } + + init() { + super.init(nibName: nil, bundle: nil)! + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + self.view = NSView() + self.view.wantsLayer = true + } + + override func viewDidLoad() { + super.viewDidLoad() + + buildUI() + } + + private lazy var avatarImageView: WWDCImageView = { + let v = WWDCImageView() + + v.widthAnchor.constraint(equalToConstant: 98).isActive = true + v.heightAnchor.constraint(equalToConstant: 98).isActive = true + v.translatesAutoresizingMaskIntoConstraints = false + + return v + }() + + private lazy var nameLabel: NSTextField = { + let f = NSTextField(labelWithString: "") + + f.font = NSFont.systemFont(ofSize: 18, weight: NSFontWeightMedium) + f.textColor = .prefsPrimaryText + f.translatesAutoresizingMaskIntoConstraints = false + f.alignment = .center + + return f + }() + + private lazy var infoLabel: WWDCTextField = { + let help = "Your community account is used to sync your data like bookmarks,\nvideo progress and favorites. It can also be used to recommend\nsessions to other viewers and share bookmarks." + + let f = WWDCTextField(wrappingLabelWithString: help) + + f.font = NSFont.systemFont(ofSize: 14, weight: NSFontWeightRegular) + f.textColor = .prefsSecondaryText + f.cell?.backgroundStyle = .dark + f.maximumNumberOfLines = 5 + f.translatesAutoresizingMaskIntoConstraints = false + f.alignment = .center + + return f + }() + + private lazy var permissionLabel: WWDCTextField = { + let help = "Your profile is currently incomplete.\nDo you want to complete it with your full name?" + + let f = WWDCTextField(wrappingLabelWithString: help) + + f.font = NSFont.systemFont(ofSize: 14, weight: NSFontWeightMedium) + f.textColor = .prefsPrimaryText + f.cell?.backgroundStyle = .dark + f.maximumNumberOfLines = 5 + f.translatesAutoresizingMaskIntoConstraints = false + f.alignment = .center + + return f + }() + + private lazy var errorLabel: NSTextField = { + let help = "This feature requires your macOS account to have an iCloud account. Please go to System Preferences and log in to your iCloud account." + + let f = NSTextField(wrappingLabelWithString: help) + + f.font = NSFont.systemFont(ofSize: 14, weight: NSFontWeightRegular) + f.textColor = .errorText + f.cell?.backgroundStyle = .dark + f.isSelectable = true + f.lineBreakMode = .byWordWrapping + f.setContentCompressionResistancePriority(NSLayoutPriorityDefaultLow, for: .horizontal) + f.allowsDefaultTighteningForTruncation = true + f.translatesAutoresizingMaskIntoConstraints = false + f.isHidden = true + + return f + }() + + private lazy var completeButton: NSButton = { + let b = NSButton(title: "Complete my profile", target: self, action: #selector(completeProfile(_:))) + + b.keyEquivalent = "\r" + b.translatesAutoresizingMaskIntoConstraints = false + + return b + }() + + private func buildUI() { + view.addSubview(nameLabel) + view.addSubview(avatarImageView) + view.addSubview(infoLabel) + view.addSubview(permissionLabel) + view.addSubview(completeButton) + + avatarImageView.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true + avatarImageView.topAnchor.constraint(equalTo: view.topAnchor, constant: 22).isActive = true + + nameLabel.centerXAnchor.constraint(equalTo: avatarImageView.centerXAnchor).isActive = true + nameLabel.topAnchor.constraint(equalTo: avatarImageView.bottomAnchor, constant: 12).isActive = true + + infoLabel.centerXAnchor.constraint(equalTo: nameLabel.centerXAnchor).isActive = true + infoLabel.topAnchor.constraint(equalTo: nameLabel.bottomAnchor, constant: 40).isActive = true + + permissionLabel.centerXAnchor.constraint(equalTo: infoLabel.centerXAnchor).isActive = true + permissionLabel.topAnchor.constraint(equalTo: infoLabel.bottomAnchor, constant: 12).isActive = true + + completeButton.centerXAnchor.constraint(equalTo: permissionLabel.centerXAnchor).isActive = true + completeButton.topAnchor.constraint(equalTo: permissionLabel.bottomAnchor, constant: 12).isActive = true + } + + private var loadingView: ModalLoadingView? + + private func updateUI() { + guard let profile = profile else { + loadingView = ModalLoadingView.show(attachedTo: view) + + return + } + + loadingView?.hide() + + avatarImageView.image = profile.avatar + avatarImageView.isRounded = true + + nameLabel.stringValue = profile.name + permissionLabel.isHidden = !profile.name.isEmpty + completeButton.isHidden = !profile.name.isEmpty + } + + @objc private func completeProfile(_ sender: Any?) { + guard let profile = profile else { return } + + loadingView?.show(in: view) + + CMSCommunityCenter.shared.promptAndUpdateUserProfileWithDiscoveredInfo(with: profile) { [weak self] newProfile, error in + self?.loadingView?.hide() + + if let error = error { + let alert = WWDCAlert.create() + alert.messageText = "Error updating profile" + alert.informativeText = error.localizedDescription + alert.runModal() + } else { + self?.profile = newProfile + } + } + } + +} diff --git a/WWDC/AppCoordinator.swift b/WWDC/AppCoordinator.swift index 34f6c63..c6cc636 100644 --- a/WWDC/AppCoordinator.swift +++ b/WWDC/AppCoordinator.swift @@ -210,6 +210,10 @@ final class AppCoordinator { refresh(nil) updateListsAfterSync() + + if Arguments.showPreferences { + showPreferences(nil) + } } func receiveNotification(with userInfo: [String : Any]) -> Bool { diff --git a/WWDC/Arguments.swift b/WWDC/Arguments.swift index 1ff5506..406f76c 100644 --- a/WWDC/Arguments.swift +++ b/WWDC/Arguments.swift @@ -22,6 +22,10 @@ struct Arguments { return args.contains("--use-test-video") } + static var showPreferences: Bool { + return args.contains("--prefs") + } + static var deloreanDate: String? { guard let deloreanIndex = args.index(of: "--delorean") else { return nil } diff --git a/WWDC/Assets.xcassets/account.imageset/Contents.json b/WWDC/Assets.xcassets/account.imageset/Contents.json new file mode 100644 index 0000000..865dd91 --- /dev/null +++ b/WWDC/Assets.xcassets/account.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "account.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "account@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "account@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + }, + "properties" : { + "template-rendering-intent" : "template" + } +} \ No newline at end of file diff --git a/WWDC/Assets.xcassets/account.imageset/account.png b/WWDC/Assets.xcassets/account.imageset/account.png new file mode 100644 index 0000000..1aaf03b Binary files /dev/null and b/WWDC/Assets.xcassets/account.imageset/account.png differ diff --git a/WWDC/Assets.xcassets/account.imageset/account@2x.png b/WWDC/Assets.xcassets/account.imageset/account@2x.png new file mode 100644 index 0000000..4d8ebea Binary files /dev/null and b/WWDC/Assets.xcassets/account.imageset/account@2x.png differ diff --git a/WWDC/Assets.xcassets/account.imageset/account@3x.png b/WWDC/Assets.xcassets/account.imageset/account@3x.png new file mode 100644 index 0000000..8870eb5 Binary files /dev/null and b/WWDC/Assets.xcassets/account.imageset/account@3x.png differ diff --git a/WWDC/Colors.swift b/WWDC/Colors.swift index 7dd9d60..c2de068 100644 --- a/WWDC/Colors.swift +++ b/WWDC/Colors.swift @@ -54,8 +54,32 @@ extension NSColor { return .black } + static var avatarBorder: NSColor { + return NSColor(calibratedRed:0.92, green:0.92, blue:0.92, alpha:1.00) + } + static var darkTitlebarBackground: NSColor { return NSColor(calibratedRed:0.06, green:0.06, blue:0.06, alpha:1.00) } + static var prefsPrimaryText: NSColor { + return NSColor(calibratedRed:0.87, green:0.87, blue:0.87, alpha:1.00) + } + + static var prefsSecondaryText: NSColor { + return NSColor(calibratedRed:0.75, green:0.75, blue:0.75, alpha:1.00) + } + + static var prefsTertiaryText: NSColor { + return NSColor(calibratedRed:0.49, green:0.49, blue:0.49, alpha:1.00) + } + + static var errorText: NSColor { + return NSColor(calibratedRed:0.85, green:0.18, blue:0.18, alpha:1.00) + } + + static var auxWindowBackground: NSColor { + return NSColor(calibratedRed:0.07, green:0.07, blue:0.07, alpha:1.00) + } + } diff --git a/WWDC/ITSwitch.h b/WWDC/ITSwitch.h new file mode 100755 index 0000000..645e526 --- /dev/null +++ b/WWDC/ITSwitch.h @@ -0,0 +1,32 @@ +// +// ITSwitch.h +// ITSwitch-Demo +// +// Created by Ilija Tovilo on 01/02/14. +// Copyright (c) 2014 Ilija Tovilo. All rights reserved. +// + +#import + +/** + * ITSwitch is a replica of UISwitch for Mac OS X + */ +IB_DESIGNABLE +@interface ITSwitch : NSControl + +/** + * @property checked - Gets or sets the switches state + */ +@property (nonatomic, assign) IBInspectable BOOL checked; + +/** + * @property tintColor - Gets or sets the switches tint + */ +@property (nonatomic, strong) IBInspectable NSColor *tintColor; + +/** + * @property disabledBorderColor - Define the switch's border color for disabled state. + */ +@property (nonatomic, strong) IBInspectable NSColor *disabledBorderColor; + +@end diff --git a/WWDC/ITSwitch.m b/WWDC/ITSwitch.m new file mode 100755 index 0000000..f09b62b --- /dev/null +++ b/WWDC/ITSwitch.m @@ -0,0 +1,540 @@ +// +// ITSwitch.m +// ITSwitch-Demo +// +// Created by Ilija Tovilo on 01/02/14. +// Copyright (c) 2014 Ilija Tovilo. All rights reserved. +// + +#import "ITSwitch.h" +#import + + +// ---------------------------------------------------- +#pragma mark - Static Constants +// ---------------------------------------------------- + +static NSTimeInterval const kAnimationDuration = 0.4f; + +static CGFloat const kBorderLineWidth = 1.f; + +static CGFloat const kGoldenRatio = 1.61803398875f; +static CGFloat const kDecreasedGoldenRatio = 1.38; + +static CGFloat const kEnabledOpacity = 1.f; +static CGFloat const kDisabledOpacity = 0.5f; + +// ---------------------------------------------------- +#pragma mark - Preprocessor +// ---------------------------------------------------- + + +#define kKnobBackgroundColor [NSColor colorWithCalibratedWhite:1.f alpha:1.f] + +#define kDisabledBorderColor [NSColor colorWithCalibratedWhite:0.f alpha:0.2f] +#define kDisabledBackgroundColor [NSColor clearColor] +#define kDefaultTintColor [NSColor colorWithCalibratedRed:0.27f green:0.86f blue:0.36f alpha:1.f] +#define kInactiveBackgroundColor [NSColor colorWithCalibratedWhite:0 alpha:0.3] + +// --------------------------------------------------------------------------------------- +#pragma mark - Interface Extension +// --------------------------------------------------------------------------------------- + +@interface ITSwitch () { + __weak id _target; + SEL _action; +} + +@property (nonatomic, getter = isActive) BOOL active; +@property (nonatomic, getter = hasDragged) BOOL dragged; +@property (nonatomic, getter = isDraggingTowardsOn) BOOL draggingTowardsOn; + +@property (nonatomic, readonly, strong) CALayer *rootLayer; +@property (nonatomic, readonly, strong) CALayer *backgroundLayer; +@property (nonatomic, readonly, strong) CALayer *knobLayer; +@property (nonatomic, readonly, strong) CALayer *knobInsideLayer; + +- (void)propagateValue:(id)value forBinding:(NSString*)binding; + +@end + + + +// --------------------------------------------------------------------------------------- +#pragma mark - ITSwitch +// --------------------------------------------------------------------------------------- + +@implementation ITSwitch +@synthesize tintColor = _tintColor, disabledBorderColor = _disabledBorderColor; + + + +// ---------------------------------------------------- +#pragma mark - Init +// ---------------------------------------------------- + +- (id)initWithCoder:(NSCoder *)coder { + self = [super initWithCoder:coder]; + if (!self) return nil; + + [self setUp]; + + return self; +} + +- (id)initWithFrame:(NSRect)frame { + self = [super initWithFrame:frame]; + if (!self) return nil; + + [self setUp]; + + return self; +} + +- (void)setUp { + // The Switch is enabled per default + self.enabled = YES; + + // Set up the layer hierarchy + [self setUpLayers]; +} + +- (void)setUpLayers { + // Root layer + _rootLayer = [CALayer layer]; + //_rootLayer.delegate = self; + self.layer = _rootLayer; + self.wantsLayer = YES; + + // Background layer + _backgroundLayer = [CALayer layer]; + _backgroundLayer.autoresizingMask = kCALayerWidthSizable | kCALayerHeightSizable; + _backgroundLayer.bounds = _rootLayer.bounds; + _backgroundLayer.anchorPoint = (CGPoint){ .x = 0.f, .y = 0.f }; + _backgroundLayer.borderWidth = kBorderLineWidth; + [_rootLayer addSublayer:_backgroundLayer]; + + // Knob layer + _knobLayer = [CALayer layer]; + _knobLayer.frame = [self rectForKnob]; + _knobLayer.autoresizingMask = kCALayerHeightSizable; + _knobLayer.backgroundColor = [kKnobBackgroundColor CGColor]; + _knobLayer.shadowColor = [[NSColor blackColor] CGColor]; + _knobLayer.shadowOffset = (CGSize){ .width = 0.f, .height = -2.f }; + _knobLayer.shadowRadius = 1.f; + _knobLayer.shadowOpacity = 0.3f; + [_rootLayer addSublayer:_knobLayer]; + + _knobInsideLayer = [CALayer layer]; + _knobInsideLayer.frame = _knobLayer.bounds; + _knobInsideLayer.autoresizingMask = kCALayerWidthSizable | kCALayerHeightSizable; + _knobInsideLayer.shadowColor = [[NSColor blackColor] CGColor]; + _knobInsideLayer.shadowOffset = (CGSize){ .width = 0.f, .height = 0.f }; + _knobInsideLayer.backgroundColor = [[NSColor whiteColor] CGColor]; + _knobInsideLayer.shadowRadius = 1.f; + _knobInsideLayer.shadowOpacity = 0.35f; + [_knobLayer addSublayer:_knobInsideLayer]; + + // Initial + [self reloadLayerSize]; + [self reloadLayer]; +} + + + +// ---------------------------------------------------- +#pragma mark - NSView +// ---------------------------------------------------- + +- (BOOL)acceptsFirstMouse:(NSEvent *)theEvent { + return YES; +} + +- (void)setFrame:(NSRect)frameRect { + [super setFrame:frameRect]; + + [self reloadLayerSize]; +} + +- (void)drawFocusRingMask { + CGFloat cornerRadius = NSHeight([self bounds])/2.0; + NSBezierPath *path = [NSBezierPath bezierPathWithRoundedRect:[self bounds] xRadius:cornerRadius yRadius:cornerRadius]; + [[NSColor blackColor] set]; + [path fill]; +} + +- (BOOL)canBecomeKeyView { + return [NSApp isFullKeyboardAccessEnabled]; +} + +- (NSRect)focusRingMaskBounds { + return [self bounds]; +} + + +// ---------------------------------------------------- +#pragma mark - Update Layer +// ---------------------------------------------------- + +- (void)reloadLayer { + [CATransaction begin]; + [CATransaction setAnimationDuration:kAnimationDuration]; + { + // ------------------------------- Animate Border + // The green part also animates, which looks kinda weird + // We'll use the background-color for now + // _backgroundLayer.borderWidth = (YES || self.isActive || self.isOn) ? NSHeight(_backgroundLayer.bounds) / 2 : kBorderLineWidth; + + // ------------------------------- Animate Colors + if (([self hasDragged] && [self isDraggingTowardsOn]) || (![self hasDragged] && [self checked])) { + _backgroundLayer.borderColor = [self.tintColor CGColor]; + _backgroundLayer.backgroundColor = [self.tintColor CGColor]; + } else { + _backgroundLayer.borderColor = [self.disabledBorderColor CGColor]; + _backgroundLayer.backgroundColor = [kDisabledBackgroundColor CGColor]; + } + + // ------------------------------- Animate Enabled-Disabled state + _rootLayer.opacity = (self.isEnabled) ? kEnabledOpacity : kDisabledOpacity; + + // ------------------------------- Animate Frame + if (![self hasDragged]) { + CAMediaTimingFunction *function = [CAMediaTimingFunction functionWithControlPoints:0.25f :1.5f :0.5f :1.f]; + [CATransaction setAnimationTimingFunction:function]; + } + + self.knobLayer.frame = [self rectForKnob]; + self.knobInsideLayer.frame = self.knobLayer.bounds; + } + [CATransaction commit]; +} + +- (void)reloadLayerSize { + [CATransaction begin]; + [CATransaction setDisableActions:YES]; + { + self.knobLayer.frame = [self rectForKnob]; + self.knobInsideLayer.frame = self.knobLayer.bounds; + + [_backgroundLayer setCornerRadius:_backgroundLayer.bounds.size.height / 2.f]; + [_knobLayer setCornerRadius:_knobLayer.bounds.size.height / 2.f]; + [_knobInsideLayer setCornerRadius:_knobLayer.bounds.size.height / 2.f]; + } + [CATransaction commit]; +} + +- (CGFloat)knobHeightForSize:(NSSize)size +{ + return size.height - (kBorderLineWidth * 2.f); +} + +- (CGRect)rectForKnob { + CGFloat height = [self knobHeightForSize:_backgroundLayer.bounds.size]; + CGFloat width = ![self isActive] ? (NSWidth(_backgroundLayer.bounds) - 2.f * kBorderLineWidth) * 1.f / kGoldenRatio : + (NSWidth(_backgroundLayer.bounds) - 2.f * kBorderLineWidth) * 1.f / kDecreasedGoldenRatio; + CGFloat x = ((![self hasDragged] && ![self checked]) || (self.hasDragged && ![self isDraggingTowardsOn])) ? + kBorderLineWidth : + NSWidth(_backgroundLayer.bounds) - width - kBorderLineWidth; + + return (CGRect) { + .size.width = width, + .size.height = height, + .origin.x = x, + .origin.y = kBorderLineWidth, + }; +} + + + +// ---------------------------------------------------- +#pragma mark - NSResponder +// ---------------------------------------------------- + +- (BOOL)acceptsFirstResponder { + return [NSApp isFullKeyboardAccessEnabled]; +} + +- (void)mouseDown:(NSEvent *)theEvent { + if (!self.isEnabled) return; + + self.active = YES; + + [self reloadLayer]; +} + +- (void)mouseDragged:(NSEvent *)theEvent { + if (!self.isEnabled) return; + + self.dragged = YES; + + NSPoint draggingPoint = [self convertPoint:[theEvent locationInWindow] fromView:nil]; + self.draggingTowardsOn = draggingPoint.x >= NSWidth(self.bounds) / 2.f; + + [self reloadLayer]; +} + +- (void)mouseUp:(NSEvent *)theEvent { + if (!self.isEnabled) return; + + self.active = NO; + + BOOL checked = (![self hasDragged]) ? ![self checked] : [self isDraggingTowardsOn]; + BOOL invokeTargetAction = (checked != [self checked]); + + self.checked = checked; + if (invokeTargetAction) [self _invokeTargetAction]; + + // Reset + self.dragged = NO; + self.draggingTowardsOn = NO; + + [self reloadLayer]; +} + +- (void)moveLeft:(id)sender { + if ([self checked]) { + self.checked = NO; + [self _invokeTargetAction]; + } +} + +- (void)moveRight:(id)sender { + if ([self checked] == NO) { + self.checked = YES; + [self _invokeTargetAction]; + } +} + +- (BOOL)performKeyEquivalent:(NSEvent *)theEvent { + BOOL handledKeyEquivalent = NO; + if ([[self window] firstResponder] == self) { + NSInteger ch = [theEvent keyCode]; + + if (ch == 49) //Space + { + self.checked = ![self checked]; + [self _invokeTargetAction]; + handledKeyEquivalent = YES; + } + } + return handledKeyEquivalent; +} + + +// ---------------------------------------------------- +#pragma mark - Accessors +// ---------------------------------------------------- + +- (id)target { + return _target; +} + +- (void)setTarget:(id)target { + _target = target; +} + +- (SEL)action { + return _action; +} + +- (void)setAction:(SEL)action { + _action = action; +} + +- (void)setChecked:(BOOL)checked { + if (_checked != checked) { + _checked = checked; + [self propagateValue:@(checked) forBinding:@"checked"]; + } + + [self reloadLayer]; +} + +- (NSColor *)tintColor { + if (!_tintColor) return kDefaultTintColor; + + return _tintColor; +} + +- (void)setTintColor:(NSColor *)tintColor { + _tintColor = tintColor; + + [self reloadLayer]; +} + +- (NSColor *)disabledBorderColor { + if (!_disabledBorderColor) return kDisabledBorderColor; + + return _disabledBorderColor; +} + +- (void)setDisabledBorderColor:(NSColor *)disabledBorderColor { + _disabledBorderColor = disabledBorderColor; + + [self reloadLayer]; +} + +- (void)setEnabled:(BOOL)enabled { + [super setEnabled:enabled]; + [self reloadLayer]; +} + +// ----------------------------------- +#pragma mark - Helpers +// ----------------------------------- + +- (void)_invokeTargetAction { + if (self.action) + [NSApp sendAction:self.action to:self.target from:self]; +} + +// ----------------------------------- +#pragma mark - Accessibility +// ----------------------------------- + +- (BOOL)accessibilityIsIgnored { + return NO; +} + +- (id)accessibilityHitTest:(NSPoint)point { + return self; +} + +- (NSArray *)accessibilityAttributeNames { + static NSArray *attributes = nil; + if (attributes == nil) + { + NSMutableArray *mutableAttributes = [[super accessibilityAttributeNames] mutableCopy]; + if (mutableAttributes == nil) + mutableAttributes = [NSMutableArray new]; + + // Add attributes + if (![mutableAttributes containsObject:NSAccessibilityValueAttribute]) + [mutableAttributes addObject:NSAccessibilityValueAttribute]; + + if (![mutableAttributes containsObject:NSAccessibilityEnabledAttribute]) + [mutableAttributes addObject:NSAccessibilityEnabledAttribute]; + + if (![mutableAttributes containsObject:NSAccessibilityDescriptionAttribute]) + [mutableAttributes addObject:NSAccessibilityDescriptionAttribute]; + + // Remove attributes + if ([mutableAttributes containsObject:NSAccessibilityChildrenAttribute]) + [mutableAttributes removeObject:NSAccessibilityChildrenAttribute]; + + attributes = [mutableAttributes copy]; + } + return attributes; +} + +- (id)accessibilityAttributeValue:(NSString *)attribute { + id retVal = nil; + if ([attribute isEqualToString:NSAccessibilityRoleAttribute]) + retVal = NSAccessibilityCheckBoxRole; + else if ([attribute isEqualToString:NSAccessibilityValueAttribute]) + retVal = [NSNumber numberWithInt:self.checked]; + else if ([attribute isEqualToString:NSAccessibilityEnabledAttribute]) + retVal = [NSNumber numberWithBool:self.enabled]; + else + retVal = [super accessibilityAttributeValue:attribute]; + return retVal; +} + +- (BOOL)accessibilityIsAttributeSettable:(NSString *)attribute { + BOOL retVal; + if ([attribute isEqualToString:NSAccessibilityValueAttribute]) + retVal = YES; + else if ([attribute isEqualToString:NSAccessibilityEnabledAttribute]) + retVal = NO; + else if ([attribute isEqualToString:NSAccessibilityDescriptionAttribute]) + retVal = NO; + else + retVal = [super accessibilityIsAttributeSettable:attribute]; + return retVal; +} + +- (void)accessibilitySetValue:(id)value forAttribute:(NSString *)attribute { + if ([attribute isEqualToString:NSAccessibilityValueAttribute]) { + BOOL invokeTargetAction = self.checked != [value boolValue]; + self.checked = [value boolValue]; + if (invokeTargetAction) { + [self _invokeTargetAction]; + } + } + else { + [super accessibilitySetValue:value forAttribute:attribute]; + } +} + +- (NSArray *)accessibilityActionNames { + static NSArray *actions = nil; + if (actions == nil) + { + NSMutableArray *mutableActions = [[super accessibilityActionNames] mutableCopy]; + if (mutableActions == nil) + mutableActions = [NSMutableArray new]; + if (![mutableActions containsObject:NSAccessibilityPressAction]) + [mutableActions addObject:NSAccessibilityPressAction]; + actions = [mutableActions copy]; + } + return actions; +} + +- (void)accessibilityPerformAction:(NSString *)actionString { + if ([actionString isEqualToString:NSAccessibilityPressAction]) { + self.checked = ![self checked]; + [self _invokeTargetAction]; + } + else { + [super accessibilityPerformAction:actionString]; + } +} + +#pragma mark - +#pragma mark Bindings Extension + +- (void)propagateValue:(id)value forBinding:(NSString*)binding +{ + NSParameterAssert(binding != nil); + + // WARNING: bindingInfo contains NSNull, so it must be accounted for + NSDictionary* bindingInfo = [self infoForBinding:binding]; + if(!bindingInfo) + return; //there is no binding + + // apply the value transformer, if one has been set + NSDictionary* bindingOptions = [bindingInfo objectForKey:NSOptionsKey]; + if(bindingOptions){ + NSValueTransformer* transformer = [bindingOptions valueForKey:NSValueTransformerBindingOption]; + if(!transformer || (id)transformer == [NSNull null]){ + NSString* transformerName = [bindingOptions valueForKey:NSValueTransformerNameBindingOption]; + if(transformerName && (id)transformerName != [NSNull null]){ + transformer = [NSValueTransformer valueTransformerForName:transformerName]; + } + } + + if(transformer && (id)transformer != [NSNull null]){ + if([[transformer class] allowsReverseTransformation]){ + value = [transformer reverseTransformedValue:value]; + } else { + NSLog(@"WARNING: binding \"%@\" has value transformer, but it doesn't allow reverse transformations in %s", binding, __PRETTY_FUNCTION__); + } + } + } + + id boundObject = [bindingInfo objectForKey:NSObservedObjectKey]; + if(!boundObject || boundObject == [NSNull null]){ + NSLog(@"ERROR: NSObservedObjectKey was nil for binding \"%@\" in %s", binding, __PRETTY_FUNCTION__); + return; + } + + NSString* boundKeyPath = [bindingInfo objectForKey:NSObservedKeyPathKey]; + if(!boundKeyPath || (id)boundKeyPath == [NSNull null]){ + NSLog(@"ERROR: NSObservedKeyPathKey was nil for binding \"%@\" in %s", binding, __PRETTY_FUNCTION__); + return; + } + + [boundObject setValue:value forKeyPath:boundKeyPath]; +} + +@end diff --git a/WWDC/ModalLoadingView.swift b/WWDC/ModalLoadingView.swift new file mode 100644 index 0000000..3063eea --- /dev/null +++ b/WWDC/ModalLoadingView.swift @@ -0,0 +1,85 @@ +// +// ModalLoadingView.swift +// WWDC +// +// Created by Guilherme Rambo on 20/05/17. +// Copyright © 2017 Guilherme Rambo. All rights reserved. +// + +import Cocoa + +class ModalLoadingView: NSView { + + private lazy var backgroundView: NSVisualEffectView = { + let v = NSVisualEffectView() + + v.material = .ultraDark + v.appearance = WWDCAppearance.appearance() + v.blendingMode = .withinWindow + v.translatesAutoresizingMaskIntoConstraints = false + v.state = .active + + return v + }() + + private lazy var spinner: NSProgressIndicator = { + let p = NSProgressIndicator() + + p.isIndeterminate = true + p.style = .spinningStyle + p.translatesAutoresizingMaskIntoConstraints = false + + return p + }() + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + + wantsLayer = true + + addSubview(backgroundView) + backgroundView.addSubview(spinner) + + backgroundView.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true + backgroundView.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true + backgroundView.topAnchor.constraint(equalTo: topAnchor).isActive = true + backgroundView.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true + + spinner.centerXAnchor.constraint(equalTo: backgroundView.centerXAnchor).isActive = true + spinner.centerYAnchor.constraint(equalTo: backgroundView.centerYAnchor).isActive = true + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + static func show(attachedTo view: NSView) -> ModalLoadingView { + let v = ModalLoadingView(frame: view.bounds) + + v.show(in: view) + + return v + } + + func show(in view: NSView) { + alphaValue = 0 + autoresizingMask = [.viewWidthSizable, .viewHeightSizable] + spinner.startAnimation(nil) + + view.addSubview(self) + + NSAnimationContext.runAnimationGroup({ _ in + self.alphaValue = 1 + }, completionHandler: nil) + } + + func hide() { + NSAnimationContext.runAnimationGroup({ _ in + self.spinner.stopAnimation(nil) + self.alphaValue = 0 + }, completionHandler: { + self.removeFromSuperview() + }) + } + +} diff --git a/WWDC/PreferencesCoordinator.swift b/WWDC/PreferencesCoordinator.swift index f369871..d857f1f 100644 --- a/WWDC/PreferencesCoordinator.swift +++ b/WWDC/PreferencesCoordinator.swift @@ -6,7 +6,10 @@ // Copyright © 2017 Guilherme Rambo. All rights reserved. // -import Foundation +import Cocoa +import CommunitySupport +import RxCocoa +import RxSwift enum PreferencesTab: Int { case account @@ -14,14 +17,27 @@ enum PreferencesTab: Int { final class PreferencesCoordinator { + private let disposeBag = DisposeBag() + private let windowController: PreferencesWindowController private let tabController: WWDCTabViewController + private let accountController: AccountPreferencesViewController init() { self.windowController = PreferencesWindowController() self.tabController = WWDCTabViewController() + // Account + self.accountController = AccountPreferencesViewController() + accountController.identifier = "Account" + let accountItem = NSTabViewItem(viewController: accountController) + accountItem.label = "Account" + self.tabController.addTabViewItem(accountItem) + + self.windowController.contentViewController = tabController + + setupAccountBindings() } func show() { @@ -29,4 +45,14 @@ final class PreferencesCoordinator { windowController.showWindow(nil) } + func setupAccountBindings() { + CMSCommunityCenter.shared.accountStatus.observeOn(MainScheduler.instance).subscribe(onNext: { [weak self] status in + self?.accountController.cloudAccountIsAvailable = (status == .available) + }).addDisposableTo(self.disposeBag) + + CMSCommunityCenter.shared.userProfile.observeOn(MainScheduler.instance).subscribe(onNext: { [weak self] profile in + self?.accountController.profile = profile + }).addDisposableTo(self.disposeBag) + } + } diff --git a/WWDC/PreferencesWindowController.swift b/WWDC/PreferencesWindowController.swift index 7296f4a..2b353de 100644 --- a/WWDC/PreferencesWindowController.swift +++ b/WWDC/PreferencesWindowController.swift @@ -15,7 +15,7 @@ class PreferencesWindowController: NSWindowController { } init() { - let mask: NSWindowStyleMask = [.titled, .miniaturizable, .closable] + let mask: NSWindowStyleMask = [.titled, .closable] let window = WWDCWindow(contentRect: PreferencesWindowController.defaultRect, styleMask: mask, backing: .buffered, defer: false) super.init(window: window) @@ -34,6 +34,8 @@ class PreferencesWindowController: NSWindowController { window.animationBehavior = .alertPanel + window.backgroundColor = .auxWindowBackground + windowDidLoad() } diff --git a/WWDC/TabItemView.swift b/WWDC/TabItemView.swift index 1ecf5c3..6ce0c70 100644 --- a/WWDC/TabItemView.swift +++ b/WWDC/TabItemView.swift @@ -64,7 +64,7 @@ final class TabItemView: NSView { didSet { if state == NSOnState { imageView.tintColor = .toolbarTintActive - imageView.image = alternateImage + imageView.image = alternateImage ?? image titleLabel.textColor = .toolbarTintActive titleLabel.font = NSFont.systemFont(ofSize: 14, weight: NSFontWeightMedium) } else { @@ -109,7 +109,9 @@ final class TabItemView: NSView { override var intrinsicContentSize: NSSize { get { - return stackView.computedContentSize + var s = stackView.computedContentSize + s.width += 12 + return s } set { @@ -122,10 +124,8 @@ final class TabItemView: NSView { wantsLayer = true addSubview(stackView) - stackView.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true - stackView.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true - stackView.topAnchor.constraint(equalTo: topAnchor).isActive = true - stackView.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true + stackView.centerXAnchor.constraint(equalTo: centerXAnchor).isActive = true + stackView.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true } required init?(coder: NSCoder) { diff --git a/WWDC/WWDC-Bridging-Header.h b/WWDC/WWDC-Bridging-Header.h index d071ffe..c9deff5 100644 --- a/WWDC/WWDC-Bridging-Header.h +++ b/WWDC/WWDC-Bridging-Header.h @@ -5,3 +5,4 @@ #import "WWDCAppearance.h" #import "NSImage+CGImage.h" #import "MaskImageView.h" +#import "ITSwitch.h" diff --git a/WWDC/WWDCImageView.swift b/WWDC/WWDCImageView.swift index f2a9466..7a16aa9 100644 --- a/WWDC/WWDCImageView.swift +++ b/WWDC/WWDCImageView.swift @@ -9,7 +9,17 @@ import Cocoa class WWDCImageView: NSView { - + + var isRounded = false { + didSet { + if isRounded { + self.layer?.mask = self.maskLayer + } else { + self.layer?.mask = nil + } + } + } + var drawsBackground = true { didSet { self.backgroundLayer.isHidden = !drawsBackground @@ -17,7 +27,7 @@ class WWDCImageView: NSView { } override var isOpaque: Bool { - return drawsBackground + return drawsBackground && !isRounded } var backgroundColor: NSColor = .clear { @@ -26,6 +36,16 @@ class WWDCImageView: NSView { } } + private lazy var maskLayer: WWDCShapeLayer = { + let l = WWDCShapeLayer() + + l.frame = self.bounds + l.autoresizingMask = [.layerWidthSizable, .layerHeightSizable] + l.path = CGPath(ellipseIn: self.bounds, transform: nil) + + return l + }() + var image: NSImage? = nil { didSet { imageLayer.contents = image @@ -73,4 +93,10 @@ class WWDCImageView: NSView { layer?.addSublayer(imageLayer) } + override func layout() { + super.layout() + + maskLayer.frame = bounds + } + } diff --git a/WWDC/WWDCLayer.swift b/WWDC/WWDCLayer.swift index a8bf6ed..dff0fe0 100644 --- a/WWDC/WWDCLayer.swift +++ b/WWDC/WWDCLayer.swift @@ -15,3 +15,11 @@ final class WWDCLayer: CALayer { } } + +final class WWDCShapeLayer: CAShapeLayer { + + override func action(forKey event: String) -> CAAction? { + return nil + } + +} diff --git a/WWDC/WWDCTabViewController.swift b/WWDC/WWDCTabViewController.swift index 76c420d..4d264a3 100644 --- a/WWDC/WWDCTabViewController.swift +++ b/WWDC/WWDCTabViewController.swift @@ -52,13 +52,22 @@ class WWDCTabViewController: NSTabViewController where Ta private var sentStatupNotification = false + private var isConfigured = false + override func viewDidAppear() { super.viewDidAppear() + configureIfNeeded() + } + + private func configureIfNeeded() { + guard !isConfigured else { return } + guard let toolbar = view.window?.toolbar else { return } + isConfigured = true + toolbar.insertItem(withItemIdentifier: NSToolbarFlexibleSpaceItemIdentifier, at: 0) - toolbar.insertItem(withItemIdentifier: NSToolbarSpaceItemIdentifier, at: 2) toolbar.insertItem(withItemIdentifier: NSToolbarFlexibleSpaceItemIdentifier, at: toolbar.items.count) addObserver(self, forKeyPath: #keyPath(selectedTabViewItemIndex), options: [.initial, .new], context: nil)