diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index 5ed4021a7..fe0c43529 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -154,6 +154,15 @@ + + + + + + + + + @@ -192,6 +201,26 @@ + + + + + + + + + + + + + + + + + + + + @@ -207,14 +236,16 @@ - - - - - - - + + + + + + + - + + + - + \ No newline at end of file diff --git a/CoreDataStack/Entity/Setting.swift b/CoreDataStack/Entity/Setting.swift new file mode 100644 index 000000000..a4907f0ab --- /dev/null +++ b/CoreDataStack/Entity/Setting.swift @@ -0,0 +1,84 @@ +// +// Setting.swift +// CoreDataStack +// +// Created by ihugo on 2021/4/9. +// + +import CoreData +import Foundation + +@objc(Setting) +public final class Setting: NSManagedObject { + @NSManaged public var appearance: String? + @NSManaged public var triggerBy: String? + @NSManaged public var domain: String? + + @NSManaged public private(set) var createdAt: Date + @NSManaged public private(set) var updatedAt: Date + + // relationships + @NSManaged public var subscription: Set? +} + +public extension Setting { + override func awakeFromInsert() { + super.awakeFromInsert() + setPrimitiveValue(Date(), forKey: #keyPath(Setting.createdAt)) + } + + func didUpdate(at networkDate: Date) { + self.updatedAt = networkDate + } + + @discardableResult + static func insert( + into context: NSManagedObjectContext, + property: Property + ) -> Setting { + let setting: Setting = context.insertObject() + setting.appearance = property.appearance + setting.triggerBy = property.triggerBy + setting.domain = property.domain + return setting + } + + func update(appearance: String?) { + guard appearance != self.appearance else { return } + self.appearance = appearance + didUpdate(at: Date()) + } + + func update(triggerBy: String?) { + guard triggerBy != self.triggerBy else { return } + self.triggerBy = triggerBy + didUpdate(at: Date()) + } +} + +public extension Setting { + struct Property { + public let appearance: String + public let triggerBy: String + public let domain: String + + public init(appearance: String, triggerBy: String, domain: String) { + self.appearance = appearance + self.triggerBy = triggerBy + self.domain = domain + } + } +} + +extension Setting: Managed { + public static var defaultSortDescriptors: [NSSortDescriptor] { + return [NSSortDescriptor(keyPath: \Setting.createdAt, ascending: false)] + } +} + +extension Setting { + public static func predicate(domain: String) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(Setting.domain), domain) + } + +} diff --git a/CoreDataStack/Entity/Subscription.swift b/CoreDataStack/Entity/Subscription.swift new file mode 100644 index 000000000..5d65129e2 --- /dev/null +++ b/CoreDataStack/Entity/Subscription.swift @@ -0,0 +1,101 @@ +// +// SettingNotification+CoreDataClass.swift +// CoreDataStack +// +// Created by ihugo on 2021/4/9. +// +// + +import Foundation +import CoreData + +@objc(Subscription) +public final class Subscription: NSManagedObject { + @NSManaged public var id: String + @NSManaged public var endpoint: String + @NSManaged public var serverKey: String + + /// four types: + /// - anyone + /// - a follower + /// - anyone I follow + /// - no one + @NSManaged public var type: String + + @NSManaged public private(set) var createdAt: Date + @NSManaged public private(set) var updatedAt: Date + + // MARK: - relationships + @NSManaged public var alert: SubscriptionAlerts? + // MARK: holder + @NSManaged public var setting: Setting? +} + +public extension Subscription { + override func awakeFromInsert() { + super.awakeFromInsert() + setPrimitiveValue(Date(), forKey: #keyPath(Subscription.createdAt)) + } + + func didUpdate(at networkDate: Date) { + self.updatedAt = networkDate + } + + @discardableResult + static func insert( + into context: NSManagedObjectContext, + property: Property + ) -> Subscription { + let setting: Subscription = context.insertObject() + setting.id = property.id + setting.endpoint = property.endpoint + setting.serverKey = property.serverKey + + return setting + } +} + +public extension Subscription { + struct Property { + public let endpoint: String + public let id: String + public let serverKey: String + public let type: String + + public init(endpoint: String, id: String, serverKey: String, type: String) { + self.endpoint = endpoint + self.id = id + self.serverKey = serverKey + self.type = type + } + } + + func updateIfNeed(property: Property) { + if self.endpoint != property.endpoint { + self.endpoint = property.endpoint + } + if self.id != property.id { + self.id = property.id + } + if self.serverKey != property.serverKey { + self.serverKey = property.serverKey + } + if self.type != property.type { + self.type = property.type + } + } +} + +extension Subscription: Managed { + public static var defaultSortDescriptors: [NSSortDescriptor] { + return [NSSortDescriptor(keyPath: \Subscription.createdAt, ascending: false)] + } +} + +extension Subscription { + + public static func predicate(id: String) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(Subscription.id), id) + } + +} diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index 3bd82fce8..000000000 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,142 +0,0 @@ -{ - "object": { - "pins": [ - { - "package": "ActiveLabel", - "repositoryURL": "https://github.com/TwidereProject/ActiveLabel.swift", - "state": { - "branch": null, - "revision": "d6cf96e0ca4f2269021bcf8f11381ab57897f84a", - "version": "4.0.0" - } - }, - { - "package": "Alamofire", - "repositoryURL": "https://github.com/Alamofire/Alamofire.git", - "state": { - "branch": null, - "revision": "eaf6e622dd41b07b251d8f01752eab31bc811493", - "version": "5.4.1" - } - }, - { - "package": "AlamofireImage", - "repositoryURL": "https://github.com/Alamofire/AlamofireImage.git", - "state": { - "branch": null, - "revision": "3e8edbeb75227f8542aa87f90240cf0424d6362f", - "version": "4.1.0" - } - }, - { - "package": "AlamofireNetworkActivityIndicator", - "repositoryURL": "https://github.com/Alamofire/AlamofireNetworkActivityIndicator", - "state": { - "branch": null, - "revision": "392bed083e8d193aca16bfa684ee24e4bcff0510", - "version": "3.1.0" - } - }, - { - "package": "CommonOSLog", - "repositoryURL": "https://github.com/MainasuK/CommonOSLog", - "state": { - "branch": null, - "revision": "c121624a30698e9886efe38aebb36ff51c01b6c2", - "version": "0.1.1" - } - }, - { - "package": "Kingfisher", - "repositoryURL": "https://github.com/onevcat/Kingfisher.git", - "state": { - "branch": null, - "revision": "daebf8ddf974164d1b9a050c8231e263f3106b09", - "version": "6.1.0" - } - }, - { - "package": "Pageboy", - "repositoryURL": "https://github.com/uias/Pageboy", - "state": { - "branch": null, - "revision": "34ecb6e7c4e0e07494960ab2f7cc9a02293915a6", - "version": "3.6.2" - } - }, - { - "package": "swift-nio", - "repositoryURL": "https://github.com/apple/swift-nio.git", - "state": { - "branch": null, - "revision": "8da5c5a4e6c5084c296b9f39dc54f00be146e0fa", - "version": "1.14.2" - } - }, - { - "package": "swift-nio-zlib-support", - "repositoryURL": "https://github.com/apple/swift-nio-zlib-support.git", - "state": { - "branch": null, - "revision": "37760e9a52030bb9011972c5213c3350fa9d41fd", - "version": "1.0.0" - } - }, - { - "package": "SwiftyJSON", - "repositoryURL": "https://github.com/SwiftyJSON/SwiftyJSON.git", - "state": { - "branch": null, - "revision": "2b6054efa051565954e1d2b9da831680026cd768", - "version": "5.0.0" - } - }, - { - "package": "Tabman", - "repositoryURL": "https://github.com/uias/Tabman", - "state": { - "branch": null, - "revision": "bce2c87659c0ed868e6ef0aa1e05a330e202533f", - "version": "2.11.0" - } - }, - { - "package": "ThirdPartyMailer", - "repositoryURL": "https://github.com/vtourraine/ThirdPartyMailer.git", - "state": { - "branch": null, - "revision": "923c60ee7588da47db8cfc4e0f5b96e5e605ef84", - "version": "1.7.1" - } - }, - { - "package": "TOCropViewController", - "repositoryURL": "https://github.com/TimOliver/TOCropViewController.git", - "state": { - "branch": null, - "revision": "dad97167bf1be16aeecd109130900995dd01c515", - "version": "2.6.0" - } - }, - { - "package": "TwitterTextEditor", - "repositoryURL": "https://github.com/MainasuK/TwitterTextEditor", - "state": { - "branch": "feature/input-view", - "revision": "1e565d13e3c26fc2bedeb418890df42f80d6e3d5", - "version": null - } - }, - { - "package": "UITextView+Placeholder", - "repositoryURL": "https://github.com/MainasuK/UITextView-Placeholder", - "state": { - "branch": null, - "revision": "20f513ded04a040cdf5467f0891849b1763ede3b", - "version": "1.4.1" - } - } - ] - }, - "version": 1 -} diff --git a/Mastodon/Extension/UIButton.swift b/Mastodon/Extension/UIButton.swift index 916ad222d..d4334baad 100644 --- a/Mastodon/Extension/UIButton.swift +++ b/Mastodon/Extension/UIButton.swift @@ -43,3 +43,23 @@ extension UIButton { } } +extension UIButton { + // https://stackoverflow.com/questions/14523348/how-to-change-the-background-color-of-a-uibutton-while-its-highlighted + private func image(withColor color: UIColor) -> UIImage? { + let rect = CGRect(x: 0.0, y: 0.0, width: 1.0, height: 1.0) + UIGraphicsBeginImageContext(rect.size) + let context = UIGraphicsGetCurrentContext() + + context?.setFillColor(color.cgColor) + context?.fill(rect) + + let image = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + + return image + } + + func setBackgroundColor(_ color: UIColor, for state: UIControl.State) { + self.setBackgroundImage(image(withColor: color), for: state) + } +} diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 14b993881..dac01a4b3 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -581,6 +581,62 @@ internal enum L10n { internal static let confirm = L10n.tr("Localizable", "Scene.ServerRules.Button.Confirm") } } + internal enum Settings { + /// Settings + internal static let title = L10n.tr("Localizable", "Scene.Settings.Title") + internal enum Section { + internal enum Appearance { + /// Automatic + internal static let automatic = L10n.tr("Localizable", "Scene.Settings.Section.Appearance.Automatic") + /// Always Dark + internal static let dark = L10n.tr("Localizable", "Scene.Settings.Section.Appearance.Dark") + /// Always Light + internal static let light = L10n.tr("Localizable", "Scene.Settings.Section.Appearance.Light") + /// Appearance + internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.Appearance.Title") + } + internal enum BoringZone { + /// Privacy Policy + internal static let privacy = L10n.tr("Localizable", "Scene.Settings.Section.BoringZone.Privacy") + /// Terms of Service + internal static let terms = L10n.tr("Localizable", "Scene.Settings.Section.BoringZone.Terms") + /// The Boring zone + internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.BoringZone.Title") + } + internal enum Notifications { + /// Boosts my post + internal static let boosts = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Boosts") + /// Favorites my post + internal static let favorites = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Favorites") + /// Follows me + internal static let follows = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Follows") + /// Mentions me + internal static let mentions = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Mentions") + /// Notifications + internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Title") + internal enum Trigger { + /// anyone + internal static let anyone = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Trigger.Anyone") + /// anyone I follow + internal static let follow = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Trigger.Follow") + /// a follower + internal static let follower = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Trigger.Follower") + /// no one + internal static let noOne = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Trigger.NoOne") + /// Notify me when + internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Trigger.Title") + } + } + internal enum SpicyZone { + /// Clear Media Cache + internal static let clear = L10n.tr("Localizable", "Scene.Settings.Section.SpicyZone.Clear") + /// Sign Out + internal static let signOut = L10n.tr("Localizable", "Scene.Settings.Section.SpicyZone.SignOut") + /// The spicy zone + internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.SpicyZone.Title") + } + } + } internal enum Welcome { /// Social networking\nback in your hands. internal static let slogan = L10n.tr("Localizable", "Scene.Welcome.Slogan") diff --git a/Mastodon/Resources/Assets.xcassets/.DS_Store b/Mastodon/Resources/Assets.xcassets/.DS_Store new file mode 100644 index 000000000..4fb9bcc55 Binary files /dev/null and b/Mastodon/Resources/Assets.xcassets/.DS_Store differ diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Label/highlight.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Label/highlight.colorset/Contents.json index 2e1ce5f3a..d853a71aa 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Label/highlight.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Label/highlight.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.851", - "green" : "0.565", - "red" : "0.169" + "blue" : "217", + "green" : "144", + "red" : "43" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/battleshipGrey.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/battleshipGrey.colorset/Contents.json new file mode 100644 index 000000000..37df8107f --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/battleshipGrey.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.200", + "blue" : "0x80", + "green" : "0x78", + "red" : "0x78" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Settings/Contents.json b/Mastodon/Resources/Assets.xcassets/Settings/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Settings/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Settings/appearance.automatic.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Settings/appearance.automatic.imageset/Contents.json new file mode 100644 index 000000000..75da4a571 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Settings/appearance.automatic.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "iPhone 11 Pro _ X - 1.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Settings/appearance.automatic.imageset/iPhone 11 Pro _ X - 1.pdf b/Mastodon/Resources/Assets.xcassets/Settings/appearance.automatic.imageset/iPhone 11 Pro _ X - 1.pdf new file mode 100644 index 000000000..868d8d8b9 Binary files /dev/null and b/Mastodon/Resources/Assets.xcassets/Settings/appearance.automatic.imageset/iPhone 11 Pro _ X - 1.pdf differ diff --git a/Mastodon/Resources/Assets.xcassets/Settings/appearance.dark.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Settings/appearance.dark.imageset/Contents.json new file mode 100644 index 000000000..6ca47e403 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Settings/appearance.dark.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "iPhone 11 Pro _ X - 1 (2).pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Settings/appearance.dark.imageset/iPhone 11 Pro _ X - 1 (2).pdf b/Mastodon/Resources/Assets.xcassets/Settings/appearance.dark.imageset/iPhone 11 Pro _ X - 1 (2).pdf new file mode 100644 index 000000000..a214d2853 Binary files /dev/null and b/Mastodon/Resources/Assets.xcassets/Settings/appearance.dark.imageset/iPhone 11 Pro _ X - 1 (2).pdf differ diff --git a/Mastodon/Resources/Assets.xcassets/Settings/appearance.light.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Settings/appearance.light.imageset/Contents.json new file mode 100644 index 000000000..86e635c39 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Settings/appearance.light.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "iPhone 11 Pro _ X - 1 (1).pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Settings/appearance.light.imageset/iPhone 11 Pro _ X - 1 (1).pdf b/Mastodon/Resources/Assets.xcassets/Settings/appearance.light.imageset/iPhone 11 Pro _ X - 1 (1).pdf new file mode 100644 index 000000000..2b8b869b0 Binary files /dev/null and b/Mastodon/Resources/Assets.xcassets/Settings/appearance.light.imageset/iPhone 11 Pro _ X - 1 (1).pdf differ diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/.DS_Store b/Mastodon/Resources/Assets.xcassets/Welcome/.DS_Store new file mode 100644 index 000000000..523b0748c Binary files /dev/null and b/Mastodon/Resources/Assets.xcassets/Welcome/.DS_Store differ diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 40000befa..6abcb4cfe 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -187,4 +187,25 @@ any server."; "Scene.ServerRules.TermsOfService" = "terms of service"; "Scene.ServerRules.Title" = "Some ground rules."; "Scene.Welcome.Slogan" = "Social networking -back in your hands."; \ No newline at end of file +back in your hands."; +"Scene.Settings.Title" = "Settings"; +"Scene.Settings.Section.Appearance.Title" = "Appearance"; +"Scene.Settings.Section.Appearance.Automatic" = "Automatic"; +"Scene.Settings.Section.Appearance.Light" = "Always Light"; +"Scene.Settings.Section.Appearance.Dark" = "Always Dark"; +"Scene.Settings.Section.Notifications.Title" = "Notifications"; +"Scene.Settings.Section.Notifications.Favorites" = "Favorites my post"; +"Scene.Settings.Section.Notifications.Follows" = "Follows me"; +"Scene.Settings.Section.Notifications.Boosts" = "Boosts my post"; +"Scene.Settings.Section.Notifications.Mentions" = "Mentions me"; +"Scene.Settings.Section.Notifications.Trigger.Anyone" = "anyone"; +"Scene.Settings.Section.Notifications.Trigger.Follower" = "a follower"; +"Scene.Settings.Section.Notifications.Trigger.Follow" = "anyone I follow"; +"Scene.Settings.Section.Notifications.Trigger.NoOne" = "no one"; +"Scene.Settings.Section.Notifications.Trigger.Title" = "Notify me when"; +"Scene.Settings.Section.BoringZone.Title" = "The Boring zone"; +"Scene.Settings.Section.BoringZone.Terms" = "Terms of Service"; +"Scene.Settings.Section.BoringZone.Privacy" = "Privacy Policy"; +"Scene.Settings.Section.SpicyZone.Title" = "The spicy zone"; +"Scene.Settings.Section.SpicyZone.Clear" = "Clear Media Cache"; +"Scene.Settings.Section.SpicyZone.SignOut" = "Sign Out"; diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift index 0c43af79e..cc051f33e 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift @@ -33,6 +33,9 @@ extension HomeTimelineViewController { guard let self = self else { return } self.showProfileAction(action) }, + UIAction(title: "Settings", image: UIImage(systemName: "escape"), attributes: []) { [weak self] action in + self?.coordinator.present(scene: .settings, from: self, transition: .modal(animated: true, completion: nil)) + }, UIAction(title: "Sign Out", image: UIImage(systemName: "escape"), attributes: .destructive) { [weak self] action in guard let self = self else { return } self.signOutAction(action) diff --git a/Mastodon/Scene/Settings/SettingsViewController.swift b/Mastodon/Scene/Settings/SettingsViewController.swift new file mode 100644 index 000000000..95bfc8aa1 --- /dev/null +++ b/Mastodon/Scene/Settings/SettingsViewController.swift @@ -0,0 +1,430 @@ +// +// SettingsViewController.swift +// Mastodon +// +// Created by ihugo on 2021/4/7. +// + +import os.log +import UIKit +import Combine +import ActiveLabel +import CoreData +import CoreDataStack + +class SettingsViewController: UIViewController, NeedsDependency { + + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + var viewModel: SettingsViewModel! { willSet { precondition(!isViewLoaded) } } + var disposeBag = Set() + + var triggerMenu: UIMenu { + let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone + let follower = L10n.Scene.Settings.Section.Notifications.Trigger.follower + let follow = L10n.Scene.Settings.Section.Notifications.Trigger.follow + let noOne = L10n.Scene.Settings.Section.Notifications.Trigger.noOne + let menu = UIMenu( + image: UIImage(systemName: "escape"), + identifier: nil, + options: .displayInline, + children: [ + UIAction(title: anyone, image: UIImage(systemName: "person.3"), attributes: []) { [weak self] action in + self?.updateTrigger(by: anyone) + }, + UIAction(title: follower, image: UIImage(systemName: "person.crop.circle.badge.plus"), attributes: []) { [weak self] action in + self?.updateTrigger(by: follower) + }, + UIAction(title: follow, image: UIImage(systemName: "person.crop.circle.badge.checkmark"), attributes: []) { [weak self] action in + self?.updateTrigger(by: follow) + }, + UIAction(title: noOne, image: UIImage(systemName: "nosign"), attributes: []) { [weak self] action in + self?.updateTrigger(by: noOne) + }, + ].reversed() + ) + return menu + } + + lazy var notifySectionHeader: UIView = { + let view = UIStackView() + view.translatesAutoresizingMaskIntoConstraints = false + view.isLayoutMarginsRelativeArrangement = true + view.layoutMargins = UIEdgeInsets(top: 15, left: 4, bottom: 5, right: 4) + view.axis = .horizontal + view.alignment = .fill + view.distribution = .equalSpacing + view.spacing = 4 + + let notifyLabel = UILabel() + notifyLabel.translatesAutoresizingMaskIntoConstraints = false + notifyLabel.font = UIFontMetrics(forTextStyle: .title3).scaledFont(for: UIFont.systemFont(ofSize: 20, weight: .semibold)) + notifyLabel.textColor = Asset.Colors.Label.primary.color + notifyLabel.text = L10n.Scene.Settings.Section.Notifications.Trigger.title + view.addArrangedSubview(notifyLabel) + view.addArrangedSubview(whoButton) + return view + }() + + lazy var whoButton: UIButton = { + let whoButton = UIButton(type: .roundedRect) + whoButton.menu = triggerMenu + whoButton.showsMenuAsPrimaryAction = true + whoButton.setBackgroundColor(Asset.Colors.battleshipGrey.color, for: .normal) + whoButton.setTitleColor(Asset.Colors.Label.primary.color, for: .normal) + if let setting = self.viewModel.setting.value, let trigger = setting.triggerBy { + whoButton.setTitle(trigger, for: .normal) + } + whoButton.titleLabel?.font = UIFontMetrics(forTextStyle: .title3).scaledFont(for: UIFont.systemFont(ofSize: 20, weight: .semibold)) + whoButton.contentEdgeInsets = UIEdgeInsets(top: 5, left: 5, bottom: 5, right: 5) + whoButton.layer.cornerRadius = 10 + whoButton.clipsToBounds = true + return whoButton + }() + + lazy var tableView: UITableView = { + // init with a frame to fix a conflict ('UIView-Encapsulated-Layout-Width' UIStackView:0x7f8c2b6c0590.width == 0) + let tableView = UITableView(frame: CGRect(x: 0, y: 0, width: 320, height: 320), style: .grouped) + tableView.translatesAutoresizingMaskIntoConstraints = false + tableView.delegate = self + tableView.rowHeight = UITableView.automaticDimension + tableView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + + tableView.register(SettingsAppearanceTableViewCell.self, forCellReuseIdentifier: "SettingsAppearanceTableViewCell") + tableView.register(SettingsToggleTableViewCell.self, forCellReuseIdentifier: "SettingsToggleTableViewCell") + tableView.register(SettingsLinkTableViewCell.self, forCellReuseIdentifier: "SettingsLinkTableViewCell") + return tableView + }() + + lazy var footerView: UIView = { + // init with a frame to fix a conflict ('UIView-Encapsulated-Layout-Height' UIStackView:0x7ffe41e47da0.height == 0) + let view = UIStackView(frame: CGRect(x: 0, y: 0, width: 320, height: 320)) + view.isLayoutMarginsRelativeArrangement = true + view.layoutMargins = UIEdgeInsets(top: 20, left: 20, bottom: 20, right: 20) + view.axis = .vertical + view.alignment = .center + + let label = ActiveLabel(style: .default) + label.textAlignment = .center + label.configure(content: "Mastodon is open source software. You can contribute or report issues on GitHub at tootsuite/mastodon (v3.3.0).") + label.delegate = self + + view.addArrangedSubview(label) + return view + }() + + override func viewDidLoad() { + super.viewDidLoad() + + setupView() + bindViewModel() + + viewModel.viewDidLoad.send() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + guard let footerView = self.tableView.tableFooterView else { + return + } + + let width = self.tableView.bounds.size.width + let size = footerView.systemLayoutSizeFitting(CGSize(width: width, height: UIView.layoutFittingCompressedSize.height)) + if footerView.frame.size.height != size.height { + footerView.frame.size.height = size.height + self.tableView.tableFooterView = footerView + } + } + + // MAKR: - Private methods + private func bindViewModel() { + let input = SettingsViewModel.Input() + _ = viewModel.transform(input: input) + } + + private func setupView() { + view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + setupNavigation() + setupTableView() + + view.addSubview(tableView) + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + } + + private func setupNavigation() { + navigationController?.navigationBar.prefersLargeTitles = true + navigationItem.rightBarButtonItem + = UIBarButtonItem(barButtonSystemItem: UIBarButtonItem.SystemItem.done, + target: self, + action: #selector(doneButtonDidClick)) + navigationItem.title = L10n.Scene.Settings.title + + let barAppearance = UINavigationBarAppearance() + barAppearance.configureWithDefaultBackground() + navigationItem.standardAppearance = barAppearance + navigationItem.compactAppearance = barAppearance + navigationItem.scrollEdgeAppearance = barAppearance + } + + private func setupTableView() { + viewModel.dataSource = UITableViewDiffableDataSource(tableView: tableView, cellProvider: { (tableView, indexPath, item) -> UITableViewCell? in + switch item { + case .apperance(let item): + guard let cell = tableView.dequeueReusableCell(withIdentifier: "SettingsAppearanceTableViewCell") as? SettingsAppearanceTableViewCell else { + assertionFailure() + return nil + } + cell.update(with: item, delegate: self) + return cell + case .notification(let item): + guard let cell = tableView.dequeueReusableCell(withIdentifier: "SettingsToggleTableViewCell") as? SettingsToggleTableViewCell else { + assertionFailure() + return nil + } + cell.update(with: item, delegate: self) + return cell + case .boringZone(let item), .spicyZone(let item): + guard let cell = tableView.dequeueReusableCell(withIdentifier: "SettingsLinkTableViewCell") as? SettingsLinkTableViewCell else { + assertionFailure() + return nil + } + cell.update(with: item) + return cell + } + }) + + tableView.tableFooterView = footerView + } + + func signout() { + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { + return + } + + context.authenticationService.signOutMastodonUser( + domain: activeMastodonAuthenticationBox.domain, + userID: activeMastodonAuthenticationBox.userID + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] result in + guard let self = self else { return } + switch result { + case .failure(let error): + assertionFailure(error.localizedDescription) + case .success(let isSignOut): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: sign out %s", ((#file as NSString).lastPathComponent), #line, #function, isSignOut ? "success" : "fail") + guard isSignOut else { return } + self.coordinator.setup() + self.coordinator.setupOnboardingIfNeeds(animated: true) + } + } + .store(in: &disposeBag) + } + + // Mark: - Actions + @objc func doneButtonDidClick() { + dismiss(animated: true, completion: nil) + } +} + +extension SettingsViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + let sections = viewModel.dataSource.snapshot().sectionIdentifiers + guard section < sections.count else { return nil } + let sectionData = sections[section] + + if section == 1 { + let header = SettingsSectionHeader( + frame: CGRect(x: 0, y: 0, width: 375, height: 66), + customView: notifySectionHeader) + header.update(title: sectionData.title) + + if let setting = self.viewModel.setting.value, let trigger = setting.triggerBy { + whoButton.setTitle(trigger, for: .normal) + } else { + let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone + whoButton.setTitle(anyone, for: .normal) + } + return header + } else { + let header = SettingsSectionHeader(frame: CGRect(x: 0, y: 0, width: 375, height: 66)) + header.update(title: sectionData.title) + return header + } + } + + // remove the gap of table's footer + func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { + return UIView() + } + + // remove the gap of table's footer + func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { + return 0 + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard indexPath.section == 2 || indexPath.section == 3 else { return } + + if indexPath.section == 2 { + coordinator.present( + scene: .webview(url: URL(string: "https://mastodon.online/terms")!), + from: self, + transition: .modal(animated: true, completion: nil)) + } + + // iTODO: clear media cache + + + // logout + if indexPath.section == 3, indexPath.row == 2 { + signout() + } + } +} + +// Update setting into core data +extension SettingsViewController { + func updateTrigger(by who: String) { + guard let setting = self.viewModel.setting.value else { return } + + context.managedObjectContext.performChanges { + setting.update(triggerBy: who) + } + .sink { (_) in + }.store(in: &disposeBag) + } + + func updateAlert(title: String?, isOn: Bool) { + guard let title = title else { return } + guard let settings = self.viewModel.setting.value else { return } + guard let triggerBy = settings.triggerBy else { return } + + var values: [Bool?]? + if let alerts = settings.subscription?.first(where: { (s) -> Bool in + return s.type == settings.triggerBy + })?.alert { + var items = [Bool?]() + items.append(alerts.favourite) + items.append(alerts.follow) + items.append(alerts.reblog) + items.append(alerts.mention) + values = items + } + guard var alertValues = values else { return } + guard alertValues.count >= 4 else { return } + + switch title { + case L10n.Scene.Settings.Section.Notifications.favorites: + alertValues[0] = isOn + case L10n.Scene.Settings.Section.Notifications.follows: + alertValues[1] = isOn + case L10n.Scene.Settings.Section.Notifications.boosts: + alertValues[2] = isOn + case L10n.Scene.Settings.Section.Notifications.mentions: + alertValues[3] = isOn + default: break + } + self.viewModel.alertUpdate.send((triggerBy: triggerBy, values: alertValues)) + } +} + +extension SettingsViewController: SettingsAppearanceTableViewCellDelegate { + func settingsAppearanceCell(_ view: SettingsAppearanceTableViewCell, didSelect: SettingsItem.AppearanceMode) { + print("[SettingsViewController]: didSelect \(didSelect)") + guard let setting = self.viewModel.setting.value else { return } + + context.managedObjectContext.performChanges { + setting.update(appearance: didSelect.rawValue) + } + .sink { (_) in + // change light / dark mode + var overrideUserInterfaceStyle: UIUserInterfaceStyle! + switch didSelect { + case .automatic: + overrideUserInterfaceStyle = .unspecified + case .light: + overrideUserInterfaceStyle = .light + case .dark: + overrideUserInterfaceStyle = .dark + } + view.window?.overrideUserInterfaceStyle = overrideUserInterfaceStyle + }.store(in: &disposeBag) + } +} + +extension SettingsViewController: SettingsToggleCellDelegate { + func settingsToggleCell(_ cell: SettingsToggleTableViewCell, didChangeStatus: Bool) { + updateAlert(title: cell.data?.title, isOn: didChangeStatus) + } +} + +extension SettingsViewController: ActiveLabelDelegate { + func activeLabel(_ activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) { + coordinator.present( + scene: .webview(url: URL(string: "https://github.com/tootsuite/mastodon")!), + from: self, + transition: .modal(animated: true, completion: nil)) + } +} + +extension SettingsViewController { + static func updateOverrideUserInterfaceStyle(window: UIWindow?) { + guard let box = AppContext.shared.authenticationService.activeMastodonAuthenticationBox.value else { + return + } + + guard let setting: Setting? = { + let domain = box.domain + let request = Setting.sortedFetchRequest + request.predicate = Setting.predicate(domain: domain) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + do { + return try AppContext.shared.managedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() else { return } + + guard let didSelect = SettingsItem.AppearanceMode(rawValue: setting?.appearance ?? "") else { + return + } + + var overrideUserInterfaceStyle: UIUserInterfaceStyle! + switch didSelect { + case .automatic: + overrideUserInterfaceStyle = .unspecified + case .light: + overrideUserInterfaceStyle = .light + case .dark: + overrideUserInterfaceStyle = .dark + } + window?.overrideUserInterfaceStyle = overrideUserInterfaceStyle + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct SettingsViewController_Previews: PreviewProvider { + + static var previews: some View { + Group { + UIViewControllerPreview { () -> UIViewController in + return SettingsViewController() + } + .previewLayout(.fixed(width: 390, height: 844)) + } + } + +} + +#endif diff --git a/Mastodon/Scene/Settings/SettingsViewModel.swift b/Mastodon/Scene/Settings/SettingsViewModel.swift new file mode 100644 index 000000000..1f5bf4e4f --- /dev/null +++ b/Mastodon/Scene/Settings/SettingsViewModel.swift @@ -0,0 +1,295 @@ +// +// SettingsViewModel.swift +// Mastodon +// +// Created by ihugo on 2021/4/7. +// + +import Combine +import CoreData +import CoreDataStack +import Foundation +import MastodonSDK +import UIKit +import os.log + +class SettingsViewModel: NSObject, NeedsDependency { + // confirm set only once + weak var context: AppContext! { willSet { precondition(context == nil) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(coordinator == nil) } } + + var dataSource: UITableViewDiffableDataSource! + var disposeBag = Set() + let viewDidLoad = PassthroughSubject() + lazy var fetchResultsController: NSFetchedResultsController = { + let fetchRequest = Setting.sortedFetchRequest + if let box = + self.context.authenticationService.activeMastodonAuthenticationBox.value { + let domain = box.domain + fetchRequest.predicate = Setting.predicate(domain: domain) + } + + fetchRequest.fetchLimit = 1 + fetchRequest.returnsObjectsAsFaults = false + let controller = NSFetchedResultsController( + fetchRequest: fetchRequest, + managedObjectContext: context.managedObjectContext, + sectionNameKeyPath: nil, + cacheName: nil + ) + controller.delegate = self + return controller + }() + let setting = CurrentValueSubject(nil) + + /// trigger when + /// - init alerts + /// - change subscription status everytime + let alertUpdate = PassthroughSubject<(triggerBy: String, values: [Bool?]), Never>() + + lazy var notificationDefaultValue: [String: [Bool?]] = { + let followerSwitchItems: [Bool?] = [true, nil, true, true] + let anyoneSwitchItems: [Bool?] = [true, true, true, true] + let noOneSwitchItems: [Bool?] = [nil, nil, nil, nil] + let followSwitchItems: [Bool?] = [true, true, true, true] + + let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone + let follower = L10n.Scene.Settings.Section.Notifications.Trigger.follower + let follow = L10n.Scene.Settings.Section.Notifications.Trigger.follow + let noOne = L10n.Scene.Settings.Section.Notifications.Trigger.noOne + return [anyone: anyoneSwitchItems, + follower: followerSwitchItems, + follow: followSwitchItems, + noOne: noOneSwitchItems] + }() + + struct Input { + } + + struct Output { + } + + init(context: AppContext, coordinator: SceneCoordinator) { + self.context = context + self.coordinator = coordinator + + super.init() + } + + func transform(input: Input?) -> Output? { + //guard let input = input else { return nil } + + // build data for table view + buildDataSource() + + // request subsription data for updating or initialization + requestSubscription() + + typealias SubscriptionResponse = Mastodon.Response.Content + alertUpdate + .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) + .flatMap { [weak self] (arg) -> AnyPublisher in + let (triggerBy, values) = arg + guard let self = self else { + return Empty().eraseToAnyPublisher() + } + guard let activeMastodonAuthenticationBox = + self.context.authenticationService.activeMastodonAuthenticationBox.value else { + return Empty().eraseToAnyPublisher() + } + guard values.count >= 4 else { + return Empty().eraseToAnyPublisher() + } + + typealias Query = Mastodon.API.Notification.CreateSubscriptionQuery + let domain = activeMastodonAuthenticationBox.domain + return self.context.apiService.changeSubscription( + domain: domain, + mastodonAuthenticationBox: activeMastodonAuthenticationBox, + query: Query(favourite: values[0], follow: values[1], reblog: values[2], mention: values[3], poll: nil), + triggerBy: triggerBy) + } + .sink { _ in + } receiveValue: { (subscription) in + } + .store(in: &disposeBag) + + + do { + try fetchResultsController.performFetch() + setting.value = fetchResultsController.fetchedObjects?.first + } catch { + assertionFailure(error.localizedDescription) + } + return nil + } + + // MARK: - Private methods + fileprivate func processDataSource(_ settings: Setting?) { + var snapshot = NSDiffableDataSourceSnapshot() + + // appearance + let appearnceMode = SettingsItem.AppearanceMode(rawValue: settings?.appearance ?? "") ?? .automatic + let appearanceItem = SettingsItem.apperance(item: appearnceMode) + let appearance = SettingsSection.apperance(title: L10n.Scene.Settings.Section.Appearance.title, selectedMode:appearanceItem) + snapshot.appendSections([appearance]) + snapshot.appendItems([appearanceItem]) + + // notifications + var switches: [Bool?]? + if let alerts = settings?.subscription?.first(where: { (s) -> Bool in + return s.type == settings?.triggerBy + })?.alert { + var items = [Bool?]() + items.append(alerts.favourite) + items.append(alerts.follow) + items.append(alerts.reblog) + items.append(alerts.mention) + switches = items + } else if let triggerBy = settings?.triggerBy, + let values = self.notificationDefaultValue[triggerBy] { + switches = values + self.alertUpdate.send((triggerBy: triggerBy, values: values)) + } else { + // fallback a default value + let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone + switches = self.notificationDefaultValue[anyone] + } + let notifications = [L10n.Scene.Settings.Section.Notifications.favorites, + L10n.Scene.Settings.Section.Notifications.follows, + L10n.Scene.Settings.Section.Notifications.boosts, + L10n.Scene.Settings.Section.Notifications.mentions,] + var notificationItems = [SettingsItem]() + for (i, noti) in notifications.enumerated() { + var value: Bool? = nil + if let switches = switches, i < switches.count { + value = switches[i] + } + + let item = SettingsItem.notification(item: SettingsItem.NotificationSwitch(title: noti, isOn: value == true, enable: value != nil)) + notificationItems.append(item) + } + let notificationSection = SettingsSection.notifications(title: L10n.Scene.Settings.Section.Notifications.title, items: notificationItems) + snapshot.appendSections([notificationSection]) + snapshot.appendItems(notificationItems) + + // boring zone + let boringLinks = [L10n.Scene.Settings.Section.BoringZone.terms, + L10n.Scene.Settings.Section.BoringZone.privacy] + var boringLinkItems = [SettingsItem]() + for l in boringLinks { + // FIXME: update color in both light and dark mode + let item = SettingsItem.boringZone(item: SettingsItem.Link(title: l, color: .systemBlue)) + boringLinkItems.append(item) + } + let boringSection = SettingsSection.boringZone(title: L10n.Scene.Settings.Section.BoringZone.title, items: boringLinkItems) + snapshot.appendSections([boringSection]) + snapshot.appendItems(boringLinkItems) + + // spicy zone + let spicyLinks = [L10n.Scene.Settings.Section.SpicyZone.clear, + L10n.Scene.Settings.Section.SpicyZone.signOut] + var spicyLinkItems = [SettingsItem]() + for l in spicyLinks { + // FIXME: update color in both light and dark mode + let item = SettingsItem.boringZone(item: SettingsItem.Link(title: l, color: .systemRed)) + spicyLinkItems.append(item) + } + let spicySection = SettingsSection.boringZone(title: L10n.Scene.Settings.Section.SpicyZone.title, items: spicyLinkItems) + snapshot.appendSections([spicySection]) + snapshot.appendItems(spicyLinkItems) + + self.dataSource.apply(snapshot, animatingDifferences: false) + } + + private func buildDataSource() { + setting.filter({ $0 != nil }).sink { [weak self] (settings) in + guard let self = self else { return } + self.processDataSource(settings) + } + .store(in: &disposeBag) + + // init with no subscription for notification + let settings: Setting? = nil + self.processDataSource(settings) + } + + private func requestSubscription() { + // request subscription of notifications + typealias SubscriptionResponse = Mastodon.Response.Content + viewDidLoad.flatMap { [weak self] (_) -> AnyPublisher in + guard let self = self, + let activeMastodonAuthenticationBox = + self.context.authenticationService.activeMastodonAuthenticationBox.value else { + return Empty().eraseToAnyPublisher() + } + + let domain = activeMastodonAuthenticationBox.domain + return self.context.apiService.subscription( + domain: domain, + mastodonAuthenticationBox: activeMastodonAuthenticationBox) + } + .sink { _ in + } receiveValue: { (subscription) in + } + .store(in: &disposeBag) + } +} + +// MARK: - NSFetchedResultsControllerDelegate +extension SettingsViewModel: NSFetchedResultsControllerDelegate { + + func controllerWillChangeContent(_ controller: NSFetchedResultsController) { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + + func controllerDidChangeContent(_ controller: NSFetchedResultsController) { + guard controller === fetchResultsController else { + return + } + + setting.value = fetchResultsController.fetchedObjects?.first + } + +} + +enum SettingsSection: Hashable { + case apperance(title: String, selectedMode: SettingsItem) + case notifications(title: String, items: [SettingsItem]) + case boringZone(title: String, items: [SettingsItem]) + case spicyZone(tilte: String, items: [SettingsItem]) + + var title: String { + switch self { + case .apperance(let title, _), + .notifications(let title, _), + .boringZone(let title, _), + .spicyZone(let title, _): + return title + } + } +} + +enum SettingsItem: Hashable { + enum AppearanceMode: String { + case automatic + case light + case dark + } + + struct NotificationSwitch: Hashable { + let title: String + let isOn: Bool + let enable: Bool + } + + struct Link: Hashable { + let title: String + let color: UIColor + } + + case apperance(item: AppearanceMode) + case notification(item: NotificationSwitch) + case boringZone(item: Link) + case spicyZone(item: Link) +} diff --git a/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift b/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift new file mode 100644 index 000000000..83932a62e --- /dev/null +++ b/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift @@ -0,0 +1,207 @@ +// +// SettingsAppearanceTableViewCell.swift +// Mastodon +// +// Created by ihugo on 2021/4/8. +// + +import UIKit + +protocol SettingsAppearanceTableViewCellDelegate: class { + func settingsAppearanceCell(_ view: SettingsAppearanceTableViewCell, didSelect: SettingsItem.AppearanceMode) +} + +class AppearanceView: UIView { + lazy var imageView: UIImageView = { + let view = UIImageView() + return view + }() + lazy var titleLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 12, weight: .regular) + label.textColor = Asset.Colors.Label.primary.color + label.textAlignment = .center + return label + }() + lazy var checkBox: UIButton = { + let button = UIButton() + button.isUserInteractionEnabled = false + button.setImage(UIImage(systemName: "circle"), for: .normal) + button.setImage(UIImage(systemName: "checkmark.circle.fill"), for: .selected) + button.imageView?.preferredSymbolConfiguration = UIImage.SymbolConfiguration(textStyle: .body) + button.imageView?.tintColor = Asset.Colors.lightSecondaryText.color + button.imageView?.contentMode = .scaleAspectFill + return button + }() + lazy var stackView: UIStackView = { + let view = UIStackView() + view.axis = .vertical + view.spacing = 10 + view.distribution = .equalSpacing + return view + }() + + var selected: Bool = false { + didSet { + checkBox.isSelected = selected + if selected { + checkBox.imageView?.tintColor = Asset.Colors.lightBrandBlue.color + } else { + checkBox.imageView?.tintColor = Asset.Colors.lightSecondaryText.color + } + } + } + + // MARK: - Methods + init(image: UIImage?, title: String) { + super.init(frame: .zero) + setupUI() + + imageView.image = image + titleLabel.text = title + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Private methods + private func setupUI() { + stackView.addArrangedSubview(imageView) + stackView.addArrangedSubview(titleLabel) + stackView.addArrangedSubview(checkBox) + + addSubview(stackView) + translatesAutoresizingMaskIntoConstraints = false + stackView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + stackView.topAnchor.constraint(equalTo: self.topAnchor), + stackView.leadingAnchor.constraint(equalTo: self.leadingAnchor), + stackView.bottomAnchor.constraint(equalTo: self.bottomAnchor), + stackView.trailingAnchor.constraint(equalTo: self.trailingAnchor), + imageView.heightAnchor.constraint(equalTo: imageView.widthAnchor, multiplier: 218.0 / 100.0), + ]) + } +} + +class SettingsAppearanceTableViewCell: UITableViewCell { + weak var delegate: SettingsAppearanceTableViewCellDelegate? + var appearance: SettingsItem.AppearanceMode = .automatic { + didSet { + guard let delegate = self.delegate else { return } + delegate.settingsAppearanceCell(self, didSelect: appearance) + } + } + + lazy var stackView: UIStackView = { + let view = UIStackView() + view.isLayoutMarginsRelativeArrangement = true + view.layoutMargins = UIEdgeInsets(top: 0, left: 20, bottom: 0, right: 20) + view.axis = .horizontal + view.distribution = .fillEqually + view.spacing = 18 + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + let automatic = AppearanceView(image: Asset.Settings.appearanceAutomatic.image, + title: L10n.Scene.Settings.Section.Appearance.automatic) + let light = AppearanceView(image: Asset.Settings.appearanceLight.image, + title: L10n.Scene.Settings.Section.Appearance.light) + let dark = AppearanceView(image: Asset.Settings.appearanceDark.image, + title: L10n.Scene.Settings.Section.Appearance.dark) + + lazy var automaticTap: UITapGestureRecognizer = { + let tapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer + tapGestureRecognizer.addTarget(self, action: #selector(appearanceDidTap(sender:))) + return tapGestureRecognizer + }() + + lazy var lightTap: UITapGestureRecognizer = { + let tapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer + tapGestureRecognizer.addTarget(self, action: #selector(appearanceDidTap(sender:))) + return tapGestureRecognizer + }() + + lazy var darkTap: UITapGestureRecognizer = { + let tapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer + tapGestureRecognizer.addTarget(self, action: #selector(appearanceDidTap(sender:))) + return tapGestureRecognizer + }() + + // MARK: - Methods + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + + // remove seperator line in section of group tableview + for subview in self.subviews { + if subview != self.contentView && subview.frame.width == self.frame.width { + subview.removeFromSuperview() + } + } + } + + func update(with data: SettingsItem.AppearanceMode, delegate: SettingsAppearanceTableViewCellDelegate?) { + appearance = data + self.delegate = delegate + + automatic.selected = false + light.selected = false + dark.selected = false + + switch data { + case .automatic: + automatic.selected = true + case .light: + light.selected = true + case .dark: + dark.selected = true + } + } + + // MARK: Private methods + private func setupUI() { + backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + selectionStyle = .none + contentView.addSubview(stackView) + + stackView.addArrangedSubview(automatic) + stackView.addArrangedSubview(light) + stackView.addArrangedSubview(dark) + + automatic.addGestureRecognizer(automaticTap) + light.addGestureRecognizer(lightTap) + dark.addGestureRecognizer(darkTap) + + NSLayoutConstraint.activate([ + stackView.topAnchor.constraint(equalTo: contentView.topAnchor), + stackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + stackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + stackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + ]) + } + + // MARK: - Actions + @objc func appearanceDidTap(sender: UIGestureRecognizer) { + if sender == automaticTap { + appearance = .automatic + } + + if sender == lightTap { + appearance = .light + } + + if sender == darkTap { + appearance = .dark + } + } +} diff --git a/Mastodon/Scene/Settings/View/Cell/SettingsLinkTableViewCell.swift b/Mastodon/Scene/Settings/View/Cell/SettingsLinkTableViewCell.swift new file mode 100644 index 000000000..b5d0306d4 --- /dev/null +++ b/Mastodon/Scene/Settings/View/Cell/SettingsLinkTableViewCell.swift @@ -0,0 +1,31 @@ +// +// SettingsLinkTableViewCell.swift +// Mastodon +// +// Created by ihugo on 2021/4/8. +// + +import UIKit + +class SettingsLinkTableViewCell: UITableViewCell { + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + selectionStyle = .none + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func setHighlighted(_ highlighted: Bool, animated: Bool) { + super.setHighlighted(highlighted, animated: animated) + textLabel?.alpha = highlighted ? 0.6 : 1.0 + } + + // MARK: - Methods + func update(with data: SettingsItem.Link) { + textLabel?.text = data.title + textLabel?.textColor = data.color + } +} diff --git a/Mastodon/Scene/Settings/View/Cell/SettingsToggleTableViewCell.swift b/Mastodon/Scene/Settings/View/Cell/SettingsToggleTableViewCell.swift new file mode 100644 index 000000000..374e05d6d --- /dev/null +++ b/Mastodon/Scene/Settings/View/Cell/SettingsToggleTableViewCell.swift @@ -0,0 +1,70 @@ +// +// SettingsToggleTableViewCell.swift +// Mastodon +// +// Created by ihugo on 2021/4/8. +// + +import UIKit + +protocol SettingsToggleCellDelegate: class { + func settingsToggleCell(_ cell: SettingsToggleTableViewCell, didChangeStatus: Bool) +} + +class SettingsToggleTableViewCell: UITableViewCell { + lazy var switchButton: UISwitch = { + let view = UISwitch(frame:.zero) + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + var data: SettingsItem.NotificationSwitch? + weak var delegate: SettingsToggleCellDelegate? + + // MARK: - Methods + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: .default, reuseIdentifier: reuseIdentifier) + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(with data: SettingsItem.NotificationSwitch, delegate: SettingsToggleCellDelegate?) { + self.delegate = delegate + self.data = data + textLabel?.text = data.title + switchButton.isOn = data.isOn + setup(enable: data.enable) + } + + // MARK: Actions + @objc func valueDidChange(sender: UISwitch) { + guard let delegate = delegate else { return } + delegate.settingsToggleCell(self, didChangeStatus: sender.isOn) + } + + // MARK: Private methods + private func setupUI() { + selectionStyle = .none + textLabel?.font = .systemFont(ofSize: 17, weight: .regular) + contentView.addSubview(switchButton) + + NSLayoutConstraint.activate([ + switchButton.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), + switchButton.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + ]) + + switchButton.addTarget(self, action: #selector(valueDidChange(sender:)), for: .valueChanged) + } + + private func setup(enable: Bool) { + if enable { + textLabel?.textColor = Asset.Colors.Label.primary.color + } else { + textLabel?.textColor = Asset.Colors.Label.secondary.color + } + switchButton.isEnabled = enable + } +} diff --git a/Mastodon/Scene/Settings/View/SettingsSectionHeader.swift b/Mastodon/Scene/Settings/View/SettingsSectionHeader.swift new file mode 100644 index 000000000..67dff9822 --- /dev/null +++ b/Mastodon/Scene/Settings/View/SettingsSectionHeader.swift @@ -0,0 +1,54 @@ +// +// SettingsSectionHeader.swift +// Mastodon +// +// Created by ihugo on 2021/4/8. +// + +import UIKit + +/// section header which supports add a custom view blelow the title +class SettingsSectionHeader: UIView { + lazy var titleLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = .systemFont(ofSize: 13, weight: .regular) + label.textColor = Asset.Colors.Label.secondary.color + return label + }() + + lazy var stackView: UIStackView = { + let view = UIStackView() + view.translatesAutoresizingMaskIntoConstraints = false + view.isLayoutMarginsRelativeArrangement = true + view.layoutMargins = UIEdgeInsets(top: 40, left: 12, bottom: 10, right: 12) + view.axis = .vertical + return view + }() + + init(frame: CGRect, customView: UIView? = nil) { + super.init(frame: frame) + + backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + stackView.addArrangedSubview(titleLabel) + if let view = customView { + stackView.addArrangedSubview(view) + } + + addSubview(stackView) + NSLayoutConstraint.activate([ + stackView.leadingAnchor.constraint(equalTo: self.leadingAnchor), + stackView.trailingAnchor.constraint(lessThanOrEqualTo: self.trailingAnchor), + stackView.bottomAnchor.constraint(equalTo: self.bottomAnchor), + stackView.topAnchor.constraint(equalTo: self.topAnchor), + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(title: String?) { + titleLabel.text = title?.uppercased() + } +} diff --git a/Mastodon/Service/APIService/APIService+Notifications.swift b/Mastodon/Service/APIService/APIService+Notifications.swift new file mode 100644 index 000000000..9bbcd180f --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Notifications.swift @@ -0,0 +1,66 @@ +// +// APIService+Settings.swift +// Mastodon +// +// Created by ihugo on 2021/4/9. +// + +import Foundation +import MastodonSDK +import Combine + +extension APIService { + + func subscription( + domain: String, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = mastodonAuthenticationBox.userAuthorization + + + return Mastodon.API.Notification.subscription( + session: session, + domain: domain, + authorization: authorization) + .flatMap { response -> AnyPublisher, Error> in + return self.backgroundManagedObjectContext.performChanges { + _ = APIService.CoreData.createOrMergeSubscription( + into: self.backgroundManagedObjectContext, + entity: response.value, + domain: domain) + } + .setFailureType(to: Error.self) + .map { _ in return response } + .eraseToAnyPublisher() + }.eraseToAnyPublisher() + } + + func changeSubscription( + domain: String, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox, + query: Mastodon.API.Notification.CreateSubscriptionQuery, + triggerBy: String + ) -> AnyPublisher, Error> { + let authorization = mastodonAuthenticationBox.userAuthorization + + return Mastodon.API.Notification.createSubscription( + session: session, + domain: domain, + authorization: authorization, + query: query + ) + .flatMap { response -> AnyPublisher, Error> in + return self.backgroundManagedObjectContext.performChanges { + _ = APIService.CoreData.createOrMergeSubscription( + into: self.backgroundManagedObjectContext, + entity: response.value, + domain: domain, + triggerBy: triggerBy) + } + .setFailureType(to: Error.self) + .map { _ in return response } + .eraseToAnyPublisher() + }.eraseToAnyPublisher() + } +} + diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Notification.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Notification.swift new file mode 100644 index 000000000..b3cd004b0 --- /dev/null +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Notification.swift @@ -0,0 +1,129 @@ +// +// APIService+CoreData+Notification.swift +// Mastodon +// +// Created by ihugo on 2021/4/11. +// + +import os.log +import Foundation +import CoreData +import CoreDataStack +import MastodonSDK + +extension APIService.CoreData { + + static func createOrMergeSetting( + into managedObjectContext: NSManagedObjectContext, + domain: String, + property: Setting.Property + ) -> (Subscription: Setting, isCreated: Bool) { + let oldSetting: Setting? = { + let request = Setting.sortedFetchRequest + request.predicate = Setting.predicate(domain: property.domain) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + do { + return try managedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + + if let oldSetting = oldSetting { + return (oldSetting, false) + } else { + let setting = Setting.insert( + into: managedObjectContext, + property: property) + return (setting, true) + } + } + + static func createOrMergeSubscription( + into managedObjectContext: NSManagedObjectContext, + entity: Mastodon.Entity.Subscription, + domain: String, + triggerBy: String? = nil + ) -> (Subscription: Subscription, isCreated: Bool) { + // create setting entity if possible + let oldSetting: Setting? = { + let request = Setting.sortedFetchRequest + request.predicate = Setting.predicate(domain: domain) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + do { + return try managedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + var setting: Setting! + if let oldSetting = oldSetting { + setting = oldSetting + } else { + let property = Setting.Property( + appearance: "automatic", + triggerBy: "anyone", + domain: domain) + (setting, _) = createOrMergeSetting( + into: managedObjectContext, + domain: domain, + property: property) + } + + let oldSubscription: Subscription? = { + let request = Subscription.sortedFetchRequest + request.predicate = Subscription.predicate(id: entity.id) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + do { + return try managedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + + let property = Subscription.Property( + endpoint: entity.endpoint, + id: entity.id, + serverKey: entity.serverKey, + type: triggerBy ?? setting.triggerBy ?? "") + let alertEntity = entity.alerts + let alert = SubscriptionAlerts.Property( + favourite: alertEntity.favourite, + follow: alertEntity.follow, + mention: alertEntity.mention, + poll: alertEntity.poll, + reblog: alertEntity.reblog) + if let oldSubscription = oldSubscription { + oldSubscription.updateIfNeed(property: property) + if nil == oldSubscription.alert { + oldSubscription.alert = SubscriptionAlerts.insert( + into: managedObjectContext, + property: alert) + } else { + oldSubscription.alert?.updateIfNeed(property: alert) + } + + if oldSubscription.alert?.hasChanges == true { + // don't expand subscription if add existed subscription + setting.mutableSetValue(forKey: #keyPath(Setting.subscription)).add(oldSubscription) + } + return (oldSubscription, false) + } else { + let subscription = Subscription.insert( + into: managedObjectContext, + property: property + ) + subscription.alert = SubscriptionAlerts.insert( + into: managedObjectContext, + property: alert) + setting.mutableSetValue(forKey: #keyPath(Setting.subscription)).add(subscription) + return (subscription, true) + } + } +} diff --git a/Mastodon/Supporting Files/SceneDelegate.swift b/Mastodon/Supporting Files/SceneDelegate.swift index e13395ccd..0f5e2bd59 100644 --- a/Mastodon/Supporting Files/SceneDelegate.swift +++ b/Mastodon/Supporting Files/SceneDelegate.swift @@ -27,6 +27,9 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { sceneCoordinator.setup() sceneCoordinator.setupOnboardingIfNeeds(animated: false) window.makeKeyAndVisible() + + // update `overrideUserInterfaceStyle` with current setting + SettingsViewController.updateOverrideUserInterfaceStyle(window: window) } func sceneDidDisconnect(_ scene: UIScene) { diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift new file mode 100644 index 000000000..555dd22d5 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift @@ -0,0 +1,135 @@ +// +// File.swift +// +// +// Created by ihugo on 2021/4/9. +// + +import Foundation +import Combine + +extension Mastodon.API.Notification { + + static func pushEndpointURL(domain: String) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("push/subscription") + } + + /// Get current subscription + /// + /// Using this endpoint to get current subscription + /// + /// - Since: 2.4.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/4/9 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/notifications/push/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - authorization: User token. Could be nil if status is public + /// - Returns: `AnyPublisher` contains `Poll` nested in the response + public static func subscription( + session: URLSession, + domain: String, + authorization: Mastodon.API.OAuth.Authorization? + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: pushEndpointURL(domain: domain), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Subscription.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + /// Change types of notifications + /// + /// Using this endpoint to change types of notifications + /// + /// - Since: 2.4.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/4/9 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/notifications/push/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - authorization: User token. Could be nil if status is public + /// - Returns: `AnyPublisher` contains `Poll` nested in the response + public static func createSubscription( + session: URLSession, + domain: String, + authorization: Mastodon.API.OAuth.Authorization?, + query: CreateSubscriptionQuery + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: pushEndpointURL(domain: domain), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Subscription.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } +} + +extension Mastodon.API.Notification { + public struct CreateSubscriptionQuery: PostQuery { + var queryItems: [URLQueryItem]? + var contentType: String? + var body: Data? + + let follow: Bool? + let favourite: Bool? + let reblog: Bool? + let mention: Bool? + let poll: Bool? + + // iTODO: missing parameters + // subscription[endpoint] + // subscription[keys][p256dh] + // subscription[keys][auth] + public init(favourite: Bool?, + follow: Bool?, + reblog: Bool?, + mention: Bool?, + poll: Bool?) { + self.follow = follow + self.favourite = favourite + self.reblog = reblog + self.mention = mention + self.poll = poll + + queryItems = [URLQueryItem]() + + if let followValue = follow?.queryItemValue { + let followItem = URLQueryItem(name: "data[alerts][follow]", value: followValue) + queryItems?.append(followItem) + } + + if let favouriteValue = favourite?.queryItemValue { + let favouriteItem = URLQueryItem(name: "data[alerts][favourite]", value: favouriteValue) + queryItems?.append(favouriteItem) + } + + if let reblogValue = reblog?.queryItemValue { + let reblogItem = URLQueryItem(name: "data[alerts][reblog]", value: reblogValue) + queryItems?.append(reblogItem) + } + + if let mentionValue = mention?.queryItemValue { + let mentionItem = URLQueryItem(name: "data[alerts][mention]", value: mentionValue) + queryItems?.append(mentionItem) + } + } + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index 2fdb9b346..d04071e9d 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -115,6 +115,7 @@ extension Mastodon.API { public enum Trends { } public enum Suggestions { } public enum Notifications { } + public enum Notification { } } extension Mastodon.API { diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Subscription.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Subscription.swift new file mode 100644 index 000000000..24bb2c189 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Subscription.swift @@ -0,0 +1,42 @@ +// +// File.swift +// +// +// Created by ihugo on 2021/4/9. +// + +import Foundation + + +extension Mastodon.Entity { + /// Subscription + /// + /// - Since: 2.4.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/4/9 + /// # Reference + /// [Document](https://docs.joinmastodon.org/entities/pushsubscription/) + public struct Subscription: Codable { + // Base + public let id: String + public let endpoint: String + public let alerts: Alerts + public let serverKey: String + + enum CodingKeys: String, CodingKey { + case id + case endpoint + case serverKey = "server_key" + case alerts + } + + public struct Alerts: Codable { + public let follow: Bool + public let favourite: Bool + public let reblog: Bool + public let mention: Bool + public let poll: Bool + } + } +} diff --git a/Podfile.lock b/Podfile.lock index 4f553c4e3..cc8600c7e 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -25,4 +25,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 30e8e3a555251a512e7b5e91183747152f126e7a -COCOAPODS: 1.10.1 +COCOAPODS: 1.10.0 diff --git a/SubscriptionAlerts.swift b/SubscriptionAlerts.swift new file mode 100644 index 000000000..928a777f5 --- /dev/null +++ b/SubscriptionAlerts.swift @@ -0,0 +1,131 @@ +// +// PushSubscriptionAlerts+CoreDataClass.swift +// CoreDataStack +// +// Created by ihugo on 2021/4/9. +// +// + +import Foundation +import CoreData + +@objc(SubscriptionAlerts) +public final class SubscriptionAlerts: NSManagedObject { + @NSManaged public var follow: Bool + @NSManaged public var favourite: Bool + @NSManaged public var reblog: Bool + @NSManaged public var mention: Bool + @NSManaged public var poll: Bool + + @NSManaged public private(set) var createdAt: Date + @NSManaged public private(set) var updatedAt: Date + + // MARK: - relationships + @NSManaged public var pushSubscription: Subscription? +} + +public extension SubscriptionAlerts { + override func awakeFromInsert() { + super.awakeFromInsert() + setPrimitiveValue(Date(), forKey: #keyPath(SubscriptionAlerts.createdAt)) + } + + func didUpdate(at networkDate: Date) { + self.updatedAt = networkDate + } + + @discardableResult + static func insert( + into context: NSManagedObjectContext, + property: Property + ) -> SubscriptionAlerts { + let alerts: SubscriptionAlerts = context.insertObject() + alerts.favourite = property.favourite + alerts.follow = property.follow + alerts.mention = property.mention + alerts.poll = property.poll + alerts.reblog = property.reblog + return alerts + } + + func update(favourite: Bool) { + guard self.favourite != favourite else { return } + self.favourite = favourite + + didUpdate(at: Date()) + } + + func update(follow: Bool) { + guard self.follow != follow else { return } + self.follow = follow + + didUpdate(at: Date()) + } + + func update(mention: Bool) { + guard self.mention != mention else { return } + self.mention = mention + + didUpdate(at: Date()) + } + + func update(poll: Bool) { + guard self.poll != poll else { return } + self.poll = poll + + didUpdate(at: Date()) + } + + func update(reblog: Bool) { + guard self.reblog != reblog else { return } + self.reblog = reblog + + didUpdate(at: Date()) + } +} + +public extension SubscriptionAlerts { + struct Property { + public let favourite: Bool + public let follow: Bool + public let mention: Bool + public let poll: Bool + public let reblog: Bool + + public init(favourite: Bool?, follow: Bool?, mention: Bool?, poll: Bool?, reblog: Bool?) { + self.favourite = favourite ?? true + self.follow = follow ?? true + self.mention = mention ?? true + self.poll = poll ?? true + self.reblog = reblog ?? true + } + } + + func updateIfNeed(property: Property) { + if self.follow != property.follow { + self.follow = property.follow + } + + if self.favourite != property.favourite { + self.favourite = property.favourite + } + + if self.reblog != property.reblog { + self.reblog = property.reblog + } + + if self.mention != property.mention { + self.mention = property.mention + } + + if self.poll != property.poll { + self.poll = property.poll + } + } +} + +extension SubscriptionAlerts: Managed { + public static var defaultSortDescriptors: [NSSortDescriptor] { + return [NSSortDescriptor(keyPath: \SubscriptionAlerts.createdAt, ascending: false)] + } +}