feature: settings

This commit is contained in:
ihugo 2021-04-08 19:47:31 +08:00
parent 239b6bac4f
commit 191370e712
33 changed files with 1989 additions and 156 deletions

View File

@ -154,6 +154,15 @@
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser"/>
<relationship name="hashtag" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Tag"/>
</entity>
<entity name="Setting" representedClassName="Setting" syncable="YES">
<attribute name="appearance" optional="YES" attributeType="String"/>
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="domain" optional="YES" attributeType="String"/>
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="triggerBy" optional="YES" attributeType="String"/>
<attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="subscription" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Subscription" inverseName="setting" inverseEntity="Subscription"/>
</entity>
<entity name="Status" representedClassName=".Status" syncable="YES">
<attribute name="content" attributeType="String"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
@ -192,6 +201,26 @@
<relationship name="replyTo" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="replyFrom" inverseEntity="Status"/>
<relationship name="tags" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Tag" inverseName="statuses" inverseEntity="Tag"/>
</entity>
<entity name="Subscription" representedClassName="Subscription" syncable="YES">
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="endpoint" optional="YES" attributeType="String"/>
<attribute name="id" optional="YES" attributeType="String"/>
<attribute name="serverKey" optional="YES" attributeType="String"/>
<attribute name="type" optional="YES" attributeType="String"/>
<attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="alert" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SubscriptionAlerts" inverseName="subscription" inverseEntity="SubscriptionAlerts"/>
<relationship name="setting" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Setting" inverseName="subscription" inverseEntity="Setting"/>
</entity>
<entity name="SubscriptionAlerts" representedClassName="SubscriptionAlerts" syncable="YES">
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="favourite" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="follow" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="mention" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="poll" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="reblog" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="subscription" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Subscription" inverseName="alert" inverseEntity="Subscription"/>
</entity>
<entity name="Tag" representedClassName=".Tag" syncable="YES">
<attribute name="createAt" attributeType="Date" defaultDateTimeInterval="631123200" usesScalarValueType="NO"/>
<attribute name="identifier" attributeType="UUID" usesScalarValueType="NO"/>
@ -207,14 +236,16 @@
<element name="Emoji" positionX="0" positionY="0" width="128" height="149"/>
<element name="History" positionX="0" positionY="0" width="128" height="119"/>
<element name="HomeTimelineIndex" positionX="0" positionY="0" width="128" height="134"/>
<element name="MastodonAuthentication" positionX="0" positionY="0" width="128" height="209"/>
<element name="MastodonUser" positionX="0" positionY="0" width="128" height="659"/>
<element name="Mention" positionX="0" positionY="0" width="128" height="134"/>
<element name="Poll" positionX="0" positionY="0" width="128" height="194"/>
<element name="PollOption" positionX="0" positionY="0" width="128" height="134"/>
<element name="PrivateNote" positionX="0" positionY="0" width="128" height="89"/>
<element name="SearchHistory" positionX="0" positionY="0" width="128" height="104"/>
<element name="MastodonAuthentication" positionX="18" positionY="162" width="128" height="209"/>
<element name="MastodonUser" positionX="0" positionY="0" width="128" height="629"/>
<element name="Mention" positionX="9" positionY="108" width="128" height="134"/>
<element name="Poll" positionX="72" positionY="162" width="128" height="194"/>
<element name="PollOption" positionX="81" positionY="171" width="128" height="134"/>
<element name="PrivateNote" positionX="72" positionY="153" width="128" height="89"/>
<element name="Setting" positionX="72" positionY="162" width="128" height="134"/>
<element name="Status" positionX="0" positionY="0" width="128" height="569"/>
<element name="Tag" positionX="0" positionY="0" width="128" height="134"/>
<element name="Subscription" positionX="81" positionY="171" width="128" height="149"/>
<element name="SubscriptionAlerts" positionX="72" positionY="162" width="128" height="149"/>
<element name="Tag" positionX="18" positionY="117" width="128" height="119"/>
</elements>
</model>
</model>

View File

@ -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<Subscription>?
}
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)
}
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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")

Binary file not shown.

View File

@ -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"

View File

@ -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
}
}

View File

@ -0,0 +1,9 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"provides-namespace" : true
}
}

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "iPhone 11 Pro _ X - 1.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "iPhone 11 Pro _ X - 1 (2).pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "iPhone 11 Pro _ X - 1 (1).pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

View File

@ -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.";
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";

View File

@ -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)

View File

@ -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<AnyCancellable>()
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 <a href=\"https://github.com/tootsuite/mastodon\">tootsuite/mastodon</a> (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

View File

@ -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<SettingsSection, SettingsItem>!
var disposeBag = Set<AnyCancellable>()
let viewDidLoad = PassthroughSubject<Void, Never>()
lazy var fetchResultsController: NSFetchedResultsController<Setting> = {
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<Setting?, Never>(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<Mastodon.Entity.Subscription>
alertUpdate
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
.flatMap { [weak self] (arg) -> AnyPublisher<SubscriptionResponse, Error> in
let (triggerBy, values) = arg
guard let self = self else {
return Empty<SubscriptionResponse, Error>().eraseToAnyPublisher()
}
guard let activeMastodonAuthenticationBox =
self.context.authenticationService.activeMastodonAuthenticationBox.value else {
return Empty<SubscriptionResponse, Error>().eraseToAnyPublisher()
}
guard values.count >= 4 else {
return Empty<SubscriptionResponse, Error>().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<SettingsSection, SettingsItem>()
// 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<Mastodon.Entity.Subscription>
viewDidLoad.flatMap { [weak self] (_) -> AnyPublisher<SubscriptionResponse, Error> in
guard let self = self,
let activeMastodonAuthenticationBox =
self.context.authenticationService.activeMastodonAuthenticationBox.value else {
return Empty<SubscriptionResponse, Error>().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<NSFetchRequestResult>) {
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
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)
}

View File

@ -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
}
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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()
}
}

View File

@ -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<Mastodon.Response.Content<Mastodon.Entity.Subscription>, Error> {
let authorization = mastodonAuthenticationBox.userAuthorization
return Mastodon.API.Notification.subscription(
session: session,
domain: domain,
authorization: authorization)
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Subscription>, 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<Mastodon.Response.Content<Mastodon.Entity.Subscription>, Error> {
let authorization = mastodonAuthenticationBox.userAuthorization
return Mastodon.API.Notification.createSubscription(
session: session,
domain: domain,
authorization: authorization,
query: query
)
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Subscription>, 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()
}
}

View File

@ -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)
}
}
}

View File

@ -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) {

View File

@ -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<Mastodon.Response.Content<Mastodon.Entity.Subscription>, 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<Mastodon.Response.Content<Mastodon.Entity.Subscription>, 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)
}
}
}
}

View File

@ -115,6 +115,7 @@ extension Mastodon.API {
public enum Trends { }
public enum Suggestions { }
public enum Notifications { }
public enum Notification { }
}
extension Mastodon.API {

View File

@ -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
}
}
}

View File

@ -25,4 +25,4 @@ SPEC CHECKSUMS:
PODFILE CHECKSUM: 30e8e3a555251a512e7b5e91183747152f126e7a
COCOAPODS: 1.10.1
COCOAPODS: 1.10.0

131
SubscriptionAlerts.swift Normal file
View File

@ -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)]
}
}