Merge branch 'develop' into feature/notification
This commit is contained in:
commit
bd9860b792
|
@ -120,4 +120,6 @@ xcuserdata
|
|||
# End of https://www.toptal.com/developers/gitignore/api/swift,swiftpm,xcode,cocoapods
|
||||
|
||||
Localization/StringsConvertor/input
|
||||
Localization/StringsConvertor/output
|
||||
Localization/StringsConvertor/output
|
||||
.DS_Store
|
||||
/Mastodon.xcworkspace/xcshareddata/swiftpm
|
||||
|
|
|
@ -171,6 +171,16 @@
|
|||
<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"/>
|
||||
<attribute name="userID" optional="YES" attributeType="String"/>
|
||||
<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"/>
|
||||
|
@ -209,6 +219,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"/>
|
||||
|
@ -232,7 +262,10 @@
|
|||
<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="Setting" positionX="72" positionY="162" width="128" height="149"/>
|
||||
<element name="Status" positionX="0" positionY="0" width="128" height="569"/>
|
||||
<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="0" positionY="0" width="128" height="134"/>
|
||||
</elements>
|
||||
</model>
|
|
@ -0,0 +1,90 @@
|
|||
//
|
||||
// Setting.swift
|
||||
// CoreDataStack
|
||||
//
|
||||
// Created by ihugo on 2021/4/9.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
import Foundation
|
||||
|
||||
public final class Setting: NSManagedObject {
|
||||
@NSManaged public var appearance: String?
|
||||
@NSManaged public var triggerBy: String?
|
||||
@NSManaged public var domain: String?
|
||||
@NSManaged public var userID: 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
|
||||
setting.userID = property.userID
|
||||
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 let userID: String
|
||||
|
||||
public init(appearance: String, triggerBy: String, domain: String, userID: String) {
|
||||
self.appearance = appearance
|
||||
self.triggerBy = triggerBy
|
||||
self.domain = domain
|
||||
self.userID = userID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Setting: Managed {
|
||||
public static var defaultSortDescriptors: [NSSortDescriptor] {
|
||||
return [NSSortDescriptor(keyPath: \Setting.createdAt, ascending: false)]
|
||||
}
|
||||
}
|
||||
|
||||
extension Setting {
|
||||
public static func predicate(domain: String, userID: String) -> NSPredicate {
|
||||
return NSPredicate(format: "%K == %@ AND %K == %@",
|
||||
#keyPath(Setting.domain), domain,
|
||||
#keyPath(Setting.userID), userID
|
||||
)
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,101 @@
|
|||
//
|
||||
// SettingNotification+CoreDataClass.swift
|
||||
// CoreDataStack
|
||||
//
|
||||
// Created by ihugo on 2021/4/9.
|
||||
//
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
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
|
||||
setting.type = property.type
|
||||
|
||||
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(type: String) -> NSPredicate {
|
||||
return NSPredicate(format: "%K == %@", #keyPath(Subscription.type), type)
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,130 @@
|
|||
//
|
||||
// PushSubscriptionAlerts+CoreDataClass.swift
|
||||
// CoreDataStack
|
||||
//
|
||||
// Created by ihugo on 2021/4/9.
|
||||
//
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
public final class SubscriptionAlerts: NSManagedObject {
|
||||
@NSManaged public var follow: NSNumber?
|
||||
@NSManaged public var favourite: NSNumber?
|
||||
@NSManaged public var reblog: NSNumber?
|
||||
@NSManaged public var mention: NSNumber?
|
||||
@NSManaged public var poll: NSNumber?
|
||||
|
||||
@NSManaged public private(set) var createdAt: Date
|
||||
@NSManaged public private(set) var updatedAt: Date
|
||||
|
||||
// MARK: - relationships
|
||||
@NSManaged public var subscription: 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: NSNumber?) {
|
||||
guard self.favourite != favourite else { return }
|
||||
self.favourite = favourite
|
||||
|
||||
didUpdate(at: Date())
|
||||
}
|
||||
|
||||
func update(follow: NSNumber?) {
|
||||
guard self.follow != follow else { return }
|
||||
self.follow = follow
|
||||
|
||||
didUpdate(at: Date())
|
||||
}
|
||||
|
||||
func update(mention: NSNumber?) {
|
||||
guard self.mention != mention else { return }
|
||||
self.mention = mention
|
||||
|
||||
didUpdate(at: Date())
|
||||
}
|
||||
|
||||
func update(poll: NSNumber?) {
|
||||
guard self.poll != poll else { return }
|
||||
self.poll = poll
|
||||
|
||||
didUpdate(at: Date())
|
||||
}
|
||||
|
||||
func update(reblog: NSNumber?) {
|
||||
guard self.reblog != reblog else { return }
|
||||
self.reblog = reblog
|
||||
|
||||
didUpdate(at: Date())
|
||||
}
|
||||
}
|
||||
|
||||
public extension SubscriptionAlerts {
|
||||
struct Property {
|
||||
public let favourite: NSNumber?
|
||||
public let follow: NSNumber?
|
||||
public let mention: NSNumber?
|
||||
public let poll: NSNumber?
|
||||
public let reblog: NSNumber?
|
||||
|
||||
public init(favourite: NSNumber?, follow: NSNumber?, mention: NSNumber?, poll: NSNumber?, reblog: NSNumber?) {
|
||||
self.favourite = favourite
|
||||
self.follow = follow
|
||||
self.mention = mention
|
||||
self.poll = poll
|
||||
self.reblog = reblog
|
||||
}
|
||||
}
|
||||
|
||||
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)]
|
||||
}
|
||||
}
|
|
@ -22,6 +22,11 @@
|
|||
"publish_post_failure": {
|
||||
"title": "Publish Failure",
|
||||
"message": "Failed to publish the post.\nPlease check your internet connection."
|
||||
},
|
||||
"sign_out": {
|
||||
"title": "Sign out",
|
||||
"message": "Are you sure you want to sign out?",
|
||||
"confirm": "Sign Out"
|
||||
}
|
||||
},
|
||||
"controls": {
|
||||
|
@ -348,6 +353,41 @@
|
|||
"single": "%s favorite",
|
||||
"multiple": "%s favorites"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"section": {
|
||||
"appearance": {
|
||||
"title": "Appearance",
|
||||
"automatic": "Automatic",
|
||||
"light": "Always Light",
|
||||
"dark": "Always Dark"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"favorites": "Favorites my post",
|
||||
"follows": "Follows me",
|
||||
"boosts": "Reblogs my post",
|
||||
"mentions": "Mentions me",
|
||||
"trigger": {
|
||||
"anyone": "anyone",
|
||||
"follower": "a follower",
|
||||
"follow": "anyone I follow",
|
||||
"noone": "no one",
|
||||
"title": "Notify me when"
|
||||
}
|
||||
},
|
||||
"boringzone": {
|
||||
"title": "The Boring zone",
|
||||
"terms": "Terms of Service",
|
||||
"privacy": "Privacy Policy"
|
||||
},
|
||||
"spicyzone": {
|
||||
"title": "The spicy zone",
|
||||
"clear": "Clear Media Cache",
|
||||
"signout": "Sign Out"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -133,6 +133,17 @@
|
|||
2DF75BC725D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BC625D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift */; };
|
||||
2DFAD5272616F9D300F9EE7C /* SearchViewController+Searching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DFAD5262616F9D300F9EE7C /* SearchViewController+Searching.swift */; };
|
||||
2DFAD5372617010500F9EE7C /* SearchingTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DFAD5362617010500F9EE7C /* SearchingTableViewCell.swift */; };
|
||||
5B90C45E262599800002E742 /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C456262599800002E742 /* SettingsViewModel.swift */; };
|
||||
5B90C45F262599800002E742 /* SettingsToggleTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C459262599800002E742 /* SettingsToggleTableViewCell.swift */; };
|
||||
5B90C460262599800002E742 /* SettingsAppearanceTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C45A262599800002E742 /* SettingsAppearanceTableViewCell.swift */; };
|
||||
5B90C461262599800002E742 /* SettingsLinkTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C45B262599800002E742 /* SettingsLinkTableViewCell.swift */; };
|
||||
5B90C462262599800002E742 /* SettingsSectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C45C262599800002E742 /* SettingsSectionHeader.swift */; };
|
||||
5B90C463262599800002E742 /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C45D262599800002E742 /* SettingsViewController.swift */; };
|
||||
5B90C46E26259B2C0002E742 /* Subscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C46C26259B2C0002E742 /* Subscription.swift */; };
|
||||
5B90C46F26259B2C0002E742 /* Setting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C46D26259B2C0002E742 /* Setting.swift */; };
|
||||
5B90C47F26259BA90002E742 /* SubscriptionAlerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C47E26259BA90002E742 /* SubscriptionAlerts.swift */; };
|
||||
5B90C48526259BF10002E742 /* APIService+Subscriptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C48426259BF10002E742 /* APIService+Subscriptions.swift */; };
|
||||
5B90C48B26259C120002E742 /* APIService+CoreData+Subscriptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C48A26259C120002E742 /* APIService+CoreData+Subscriptions.swift */; };
|
||||
5D0393902612D259007FE196 /* WebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D03938F2612D259007FE196 /* WebViewController.swift */; };
|
||||
5D0393962612D266007FE196 /* WebViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D0393952612D266007FE196 /* WebViewModel.swift */; };
|
||||
5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 5D526FE125BE9AC400460CB9 /* MastodonSDK */; };
|
||||
|
@ -536,6 +547,17 @@
|
|||
3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_MastodonUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MastodonTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
459EA4F43058CAB47719E963 /* Pods-Mastodon-MastodonUITests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
5B90C456262599800002E742 /* SettingsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = "<group>"; };
|
||||
5B90C459262599800002E742 /* SettingsToggleTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsToggleTableViewCell.swift; sourceTree = "<group>"; };
|
||||
5B90C45A262599800002E742 /* SettingsAppearanceTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsAppearanceTableViewCell.swift; sourceTree = "<group>"; };
|
||||
5B90C45B262599800002E742 /* SettingsLinkTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsLinkTableViewCell.swift; sourceTree = "<group>"; };
|
||||
5B90C45C262599800002E742 /* SettingsSectionHeader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsSectionHeader.swift; sourceTree = "<group>"; };
|
||||
5B90C45D262599800002E742 /* SettingsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = "<group>"; };
|
||||
5B90C46C26259B2C0002E742 /* Subscription.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Subscription.swift; sourceTree = "<group>"; };
|
||||
5B90C46D26259B2C0002E742 /* Setting.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Setting.swift; sourceTree = "<group>"; };
|
||||
5B90C47E26259BA90002E742 /* SubscriptionAlerts.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubscriptionAlerts.swift; sourceTree = "<group>"; };
|
||||
5B90C48426259BF10002E742 /* APIService+Subscriptions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+Subscriptions.swift"; sourceTree = "<group>"; };
|
||||
5B90C48A26259C120002E742 /* APIService+CoreData+Subscriptions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Subscriptions.swift"; sourceTree = "<group>"; };
|
||||
5D03938F2612D259007FE196 /* WebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewController.swift; sourceTree = "<group>"; };
|
||||
5D0393952612D266007FE196 /* WebViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewModel.swift; sourceTree = "<group>"; };
|
||||
5DA732CB2629CEF500A92342 /* UIView+Remove.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Remove.swift"; sourceTree = "<group>"; };
|
||||
|
@ -1195,6 +1217,35 @@
|
|||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
5B90C455262599800002E742 /* Settings */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5B90C457262599800002E742 /* View */,
|
||||
5B90C456262599800002E742 /* SettingsViewModel.swift */,
|
||||
5B90C45D262599800002E742 /* SettingsViewController.swift */,
|
||||
);
|
||||
path = Settings;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
5B90C457262599800002E742 /* View */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5B90C458262599800002E742 /* Cell */,
|
||||
5B90C45C262599800002E742 /* SettingsSectionHeader.swift */,
|
||||
);
|
||||
path = View;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
5B90C458262599800002E742 /* Cell */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5B90C459262599800002E742 /* SettingsToggleTableViewCell.swift */,
|
||||
5B90C45A262599800002E742 /* SettingsAppearanceTableViewCell.swift */,
|
||||
5B90C45B262599800002E742 /* SettingsLinkTableViewCell.swift */,
|
||||
);
|
||||
path = Cell;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
5D03938E2612D200007FE196 /* Webview */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -1381,6 +1432,7 @@
|
|||
DBAE3F932616E28B004B8251 /* APIService+Follow.swift */,
|
||||
DBAE3F8D2616E0B1004B8251 /* APIService+Block.swift */,
|
||||
DBAE3F9D2616E308004B8251 /* APIService+Mute.swift */,
|
||||
5B90C48426259BF10002E742 /* APIService+Subscriptions.swift */,
|
||||
);
|
||||
path = APIService;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1392,6 +1444,7 @@
|
|||
DB45FADC25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift */,
|
||||
DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */,
|
||||
2D79E700261EA5550011E398 /* APIService+CoreData+Tag.swift */,
|
||||
5B90C48A26259C120002E742 /* APIService+CoreData+Subscriptions.swift */,
|
||||
);
|
||||
path = CoreData;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1563,6 +1616,9 @@
|
|||
DB4481AC25EE155900BEFB67 /* Poll.swift */,
|
||||
DB4481B225EE16D000BEFB67 /* PollOption.swift */,
|
||||
DBCC3B9A2615849F0045B23D /* PrivateNote.swift */,
|
||||
5B90C46D26259B2C0002E742 /* Setting.swift */,
|
||||
5B90C46C26259B2C0002E742 /* Subscription.swift */,
|
||||
5B90C47E26259BA90002E742 /* SubscriptionAlerts.swift */,
|
||||
);
|
||||
path = Entity;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1614,6 +1670,7 @@
|
|||
2D76316325C14BAC00929FB9 /* PublicTimeline */,
|
||||
0F2021F5261325ED000C64BF /* HashtagTimeline */,
|
||||
DB9D6BEE25E4F5370051B173 /* Search */,
|
||||
5B90C455262599800002E742 /* Settings */,
|
||||
DB9D6BFD25E4F57B0051B173 /* Notification */,
|
||||
DB9D6C0825E4F5A60051B173 /* Profile */,
|
||||
DB789A1025F9F29B0071ACA0 /* Compose */,
|
||||
|
@ -2316,6 +2373,7 @@
|
|||
DBE3CE07261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift in Sources */,
|
||||
2D61335825C188A000CAE157 /* APIService+Persist+Status.swift in Sources */,
|
||||
2D34D9DB261494120081BFC0 /* APIService+Search.swift in Sources */,
|
||||
5B90C48B26259C120002E742 /* APIService+CoreData+Subscriptions.swift in Sources */,
|
||||
DBB5256E2612D5A1002F1F29 /* ProfileStatusDashboardView.swift in Sources */,
|
||||
2D24E1232626ED9D00A59D4F /* UIView+Gesture.swift in Sources */,
|
||||
DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */,
|
||||
|
@ -2330,6 +2388,7 @@
|
|||
DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */,
|
||||
DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */,
|
||||
2D79E701261EA5550011E398 /* APIService+CoreData+Tag.swift in Sources */,
|
||||
5B90C461262599800002E742 /* SettingsLinkTableViewCell.swift in Sources */,
|
||||
DBE3CDEC261C6B2900430CC6 /* FavoriteViewController.swift in Sources */,
|
||||
DB938EE62623F50700E5B6C1 /* ThreadViewController.swift in Sources */,
|
||||
2D939AB525EDD8A90076FA61 /* String.swift in Sources */,
|
||||
|
@ -2362,6 +2421,7 @@
|
|||
5DDDF1A92617489F00311060 /* Mastodon+Entity+History.swift in Sources */,
|
||||
DB59F11825EFA35B001F1DAB /* StripProgressView.swift in Sources */,
|
||||
DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */,
|
||||
5B90C463262599800002E742 /* SettingsViewController.swift in Sources */,
|
||||
DB73B490261F030A002E9E9F /* SafariActivity.swift in Sources */,
|
||||
DB9A48962603685D008B817C /* MastodonAttachmentService+UploadState.swift in Sources */,
|
||||
2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */,
|
||||
|
@ -2369,6 +2429,7 @@
|
|||
DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */,
|
||||
2DF75B9B25D0E27500694EC8 /* StatusProviderFacade.swift in Sources */,
|
||||
DB5086A525CC0B7000C2C187 /* AvatarBarButtonItem.swift in Sources */,
|
||||
5B90C45E262599800002E742 /* SettingsViewModel.swift in Sources */,
|
||||
2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */,
|
||||
5DF1054725F8870E00D6C0D4 /* VideoPlayerViewModel.swift in Sources */,
|
||||
2DE0FAC12615F04D00CDF649 /* RecommendHashTagSection.swift in Sources */,
|
||||
|
@ -2390,6 +2451,7 @@
|
|||
DB7F48452620241000796008 /* ProfileHeaderViewModel.swift in Sources */,
|
||||
2D3F9E0425DFA133004262D9 /* UITapGestureRecognizer.swift in Sources */,
|
||||
5DDDF1992617447F00311060 /* Mastodon+Entity+Tag.swift in Sources */,
|
||||
5B90C45F262599800002E742 /* SettingsToggleTableViewCell.swift in Sources */,
|
||||
2D694A7425F9EB4E0038ADDC /* ContentWarningOverlayView.swift in Sources */,
|
||||
DBAE3F682615DD60004B8251 /* UserProvider.swift in Sources */,
|
||||
DB9A486C26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift in Sources */,
|
||||
|
@ -2399,6 +2461,7 @@
|
|||
0F20222D261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift in Sources */,
|
||||
DB4563BD25E11A24004DA0B9 /* KeyboardResponderService.swift in Sources */,
|
||||
DB5086BE25CC0D9900C2C187 /* SplashPreference.swift in Sources */,
|
||||
5B90C462262599800002E742 /* SettingsSectionHeader.swift in Sources */,
|
||||
DB44768B260B3F2100B66B82 /* CustomEmojiPickerItem.swift in Sources */,
|
||||
2DF75BA125D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift in Sources */,
|
||||
5DF1056425F887CB00D6C0D4 /* AVPlayer.swift in Sources */,
|
||||
|
@ -2481,7 +2544,9 @@
|
|||
DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */,
|
||||
DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */,
|
||||
2D32EADA25CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift in Sources */,
|
||||
5B90C48526259BF10002E742 /* APIService+Subscriptions.swift in Sources */,
|
||||
0F20223926146553000C64BF /* Array+removeDuplicates.swift in Sources */,
|
||||
5B90C460262599800002E742 /* SettingsAppearanceTableViewCell.swift in Sources */,
|
||||
DB8AF54425C13647002E6C99 /* SceneCoordinator.swift in Sources */,
|
||||
5DF1058525F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift in Sources */,
|
||||
DB447697260B439000B66B82 /* CustomEmojiPickerHeaderCollectionReusableView.swift in Sources */,
|
||||
|
@ -2567,6 +2632,7 @@
|
|||
2D927F1425C7EDD9004F19B8 /* Emoji.swift in Sources */,
|
||||
2DF75BC725D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift in Sources */,
|
||||
DB89BA1225C1105C008580ED /* CoreDataStack.swift in Sources */,
|
||||
5B90C46F26259B2C0002E742 /* Setting.swift in Sources */,
|
||||
DB89BA1C25C1107F008580ED /* NSManagedObjectContext.swift in Sources */,
|
||||
DB9D6C2E25E504AC0051B173 /* Attachment.swift in Sources */,
|
||||
2D927F0E25C7E9C9004F19B8 /* History.swift in Sources */,
|
||||
|
@ -2588,6 +2654,8 @@
|
|||
DB89BA1D25C1107F008580ED /* URL.swift in Sources */,
|
||||
2D0B7A1D261D839600B44727 /* SearchHistory.swift in Sources */,
|
||||
2D927F0825C7E9A8004F19B8 /* Tag.swift in Sources */,
|
||||
5B90C46E26259B2C0002E742 /* Subscription.swift in Sources */,
|
||||
5B90C47F26259BA90002E742 /* SubscriptionAlerts.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -68,6 +68,7 @@ extension SceneCoordinator {
|
|||
|
||||
#if DEBUG
|
||||
case publicTimeline
|
||||
case settings
|
||||
#endif
|
||||
|
||||
var isOnboarding: Bool {
|
||||
|
@ -269,6 +270,10 @@ private extension SceneCoordinator {
|
|||
let _viewController = PublicTimelineViewController()
|
||||
_viewController.viewModel = PublicTimelineViewModel(context: appContext)
|
||||
viewController = _viewController
|
||||
case .settings:
|
||||
let _viewController = SettingsViewController()
|
||||
_viewController.viewModel = SettingsViewModel(context: appContext, coordinator: self)
|
||||
viewController = _viewController
|
||||
#endif
|
||||
}
|
||||
|
||||
|
|
|
@ -43,3 +43,11 @@ extension UIButton {
|
|||
}
|
||||
}
|
||||
|
||||
extension UIButton {
|
||||
func setBackgroundColor(_ color: UIColor, for state: UIControl.State) {
|
||||
self.setBackgroundImage(
|
||||
UIImage.placeholder(color: color),
|
||||
for: state
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -86,6 +86,7 @@ internal enum Asset {
|
|||
internal static let invalid = ColorAsset(name: "Colors/TextField/invalid")
|
||||
internal static let valid = ColorAsset(name: "Colors/TextField/valid")
|
||||
}
|
||||
internal static let battleshipGrey = ColorAsset(name: "Colors/battleshipGrey")
|
||||
internal static let brandBlue = ColorAsset(name: "Colors/brand.blue")
|
||||
internal static let danger = ColorAsset(name: "Colors/danger")
|
||||
internal static let disabled = ColorAsset(name: "Colors/disabled")
|
||||
|
@ -126,6 +127,11 @@ internal enum Asset {
|
|||
internal static let mastodonLogoLarge = ImageAsset(name: "Scene/Welcome/mastodon.logo.large")
|
||||
}
|
||||
}
|
||||
internal enum Settings {
|
||||
internal static let appearanceAutomatic = ImageAsset(name: "Settings/appearance.automatic")
|
||||
internal static let appearanceDark = ImageAsset(name: "Settings/appearance.dark")
|
||||
internal static let appearanceLight = ImageAsset(name: "Settings/appearance.light")
|
||||
}
|
||||
}
|
||||
// swiftlint:enable identifier_name line_length nesting type_body_length type_name
|
||||
|
||||
|
|
|
@ -35,6 +35,14 @@ internal enum L10n {
|
|||
/// Server Error
|
||||
internal static let title = L10n.tr("Localizable", "Common.Alerts.ServerError.Title")
|
||||
}
|
||||
internal enum SignOut {
|
||||
/// Sign Out
|
||||
internal static let confirm = L10n.tr("Localizable", "Common.Alerts.SignOut.Confirm")
|
||||
/// Are you sure you want to sign out?
|
||||
internal static let message = L10n.tr("Localizable", "Common.Alerts.SignOut.Message")
|
||||
/// Sign out
|
||||
internal static let title = L10n.tr("Localizable", "Common.Alerts.SignOut.Title")
|
||||
}
|
||||
internal enum SignUpFailure {
|
||||
/// Sign Up Failure
|
||||
internal static let title = L10n.tr("Localizable", "Common.Alerts.SignUpFailure.Title")
|
||||
|
@ -611,6 +619,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 {
|
||||
/// Reblogs 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 Thread {
|
||||
/// Post
|
||||
internal static let backTitle = L10n.tr("Localizable", "Scene.Thread.BackTitle")
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"provides-namespace" : true
|
||||
}
|
||||
}
|
12
Mastodon/Resources/Assets.xcassets/Settings/appearance.automatic.imageset/Contents.json
vendored
Normal file
12
Mastodon/Resources/Assets.xcassets/Settings/appearance.automatic.imageset/Contents.json
vendored
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "iPhone 11 Pro _ X - 1.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
BIN
Mastodon/Resources/Assets.xcassets/Settings/appearance.automatic.imageset/iPhone 11 Pro _ X - 1.pdf
vendored
Normal file
BIN
Mastodon/Resources/Assets.xcassets/Settings/appearance.automatic.imageset/iPhone 11 Pro _ X - 1.pdf
vendored
Normal file
Binary file not shown.
12
Mastodon/Resources/Assets.xcassets/Settings/appearance.dark.imageset/Contents.json
vendored
Normal file
12
Mastodon/Resources/Assets.xcassets/Settings/appearance.dark.imageset/Contents.json
vendored
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "iPhone 11 Pro _ X - 1 (2).pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
BIN
Mastodon/Resources/Assets.xcassets/Settings/appearance.dark.imageset/iPhone 11 Pro _ X - 1 (2).pdf
vendored
Normal file
BIN
Mastodon/Resources/Assets.xcassets/Settings/appearance.dark.imageset/iPhone 11 Pro _ X - 1 (2).pdf
vendored
Normal file
Binary file not shown.
12
Mastodon/Resources/Assets.xcassets/Settings/appearance.light.imageset/Contents.json
vendored
Normal file
12
Mastodon/Resources/Assets.xcassets/Settings/appearance.light.imageset/Contents.json
vendored
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "iPhone 11 Pro _ X - 1 (1).pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
BIN
Mastodon/Resources/Assets.xcassets/Settings/appearance.light.imageset/iPhone 11 Pro _ X - 1 (1).pdf
vendored
Normal file
BIN
Mastodon/Resources/Assets.xcassets/Settings/appearance.light.imageset/iPhone 11 Pro _ X - 1 (1).pdf
vendored
Normal file
Binary file not shown.
|
@ -6,6 +6,9 @@
|
|||
Please check your internet connection.";
|
||||
"Common.Alerts.PublishPostFailure.Title" = "Publish Failure";
|
||||
"Common.Alerts.ServerError.Title" = "Server Error";
|
||||
"Common.Alerts.SignOut.Confirm" = "Sign Out";
|
||||
"Common.Alerts.SignOut.Message" = "Are you sure you want to sign out?";
|
||||
"Common.Alerts.SignOut.Title" = "Sign out";
|
||||
"Common.Alerts.SignUpFailure.Title" = "Sign Up Failure";
|
||||
"Common.Alerts.VoteFailure.PollExpired" = "The poll has expired";
|
||||
"Common.Alerts.VoteFailure.Title" = "Vote Failure";
|
||||
|
@ -196,6 +199,27 @@ any server.";
|
|||
"Scene.ServerRules.Subtitle" = "These rules are set by the admins of %@.";
|
||||
"Scene.ServerRules.TermsOfService" = "terms of service";
|
||||
"Scene.ServerRules.Title" = "Some ground rules.";
|
||||
"Scene.Settings.Section.Appearance.Automatic" = "Automatic";
|
||||
"Scene.Settings.Section.Appearance.Dark" = "Always Dark";
|
||||
"Scene.Settings.Section.Appearance.Light" = "Always Light";
|
||||
"Scene.Settings.Section.Appearance.Title" = "Appearance";
|
||||
"Scene.Settings.Section.Boringzone.Privacy" = "Privacy Policy";
|
||||
"Scene.Settings.Section.Boringzone.Terms" = "Terms of Service";
|
||||
"Scene.Settings.Section.Boringzone.Title" = "The Boring zone";
|
||||
"Scene.Settings.Section.Notifications.Boosts" = "Reblogs my post";
|
||||
"Scene.Settings.Section.Notifications.Favorites" = "Favorites my post";
|
||||
"Scene.Settings.Section.Notifications.Follows" = "Follows me";
|
||||
"Scene.Settings.Section.Notifications.Mentions" = "Mentions me";
|
||||
"Scene.Settings.Section.Notifications.Title" = "Notifications";
|
||||
"Scene.Settings.Section.Notifications.Trigger.Anyone" = "anyone";
|
||||
"Scene.Settings.Section.Notifications.Trigger.Follow" = "anyone I follow";
|
||||
"Scene.Settings.Section.Notifications.Trigger.Follower" = "a follower";
|
||||
"Scene.Settings.Section.Notifications.Trigger.Noone" = "no one";
|
||||
"Scene.Settings.Section.Notifications.Trigger.Title" = "Notify me when";
|
||||
"Scene.Settings.Section.Spicyzone.Clear" = "Clear Media Cache";
|
||||
"Scene.Settings.Section.Spicyzone.Signout" = "Sign Out";
|
||||
"Scene.Settings.Section.Spicyzone.Title" = "The spicy zone";
|
||||
"Scene.Settings.Title" = "Settings";
|
||||
"Scene.Thread.BackTitle" = "Post";
|
||||
"Scene.Thread.Favorite.Multiple" = "%@ favorites";
|
||||
"Scene.Thread.Favorite.Single" = "%@ favorite";
|
||||
|
@ -203,4 +227,4 @@ any server.";
|
|||
"Scene.Thread.Reblog.Single" = "%@ reblog";
|
||||
"Scene.Thread.Title" = "Post from %@";
|
||||
"Scene.Welcome.Slogan" = "Social networking
|
||||
back in your hands.";
|
||||
back in your hands.";
|
||||
|
|
|
@ -37,6 +37,10 @@ extension HomeTimelineViewController {
|
|||
guard let self = self else { return }
|
||||
self.showThreadAction(action)
|
||||
},
|
||||
UIAction(title: "Settings", image: UIImage(systemName: "gear"), attributes: []) { [weak self] action in
|
||||
guard let self = self else { return }
|
||||
self.showSettings(action)
|
||||
},
|
||||
UIAction(title: "Sign Out", image: UIImage(systemName: "escape"), attributes: .destructive) { [weak self] action in
|
||||
guard let self = self else { return }
|
||||
self.signOutAction(action)
|
||||
|
@ -323,5 +327,8 @@ extension HomeTimelineViewController {
|
|||
coordinator.present(scene: .alertController(alertController: alertController), from: self, transition: .alertController(animated: true, completion: nil))
|
||||
}
|
||||
|
||||
@objc private func showSettings(_ sender: UIAction) {
|
||||
coordinator.present(scene: .settings, from: self, transition: .modal(animated: true, completion: nil))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
|
@ -0,0 +1,488 @@
|
|||
//
|
||||
// SettingsViewController.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by ihugo on 2021/4/7.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import Combine
|
||||
import ActiveLabel
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
import AlamofireImage
|
||||
import Kingfisher
|
||||
|
||||
// iTODO: when to ask permission to Use Notifications
|
||||
|
||||
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: nil,
|
||||
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)
|
||||
},
|
||||
]
|
||||
)
|
||||
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: { [weak self] (tableView, indexPath, item) -> UITableViewCell? in
|
||||
guard let self = self else { return nil }
|
||||
|
||||
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 alertToSignout() {
|
||||
let alertController = UIAlertController(
|
||||
title: L10n.Common.Alerts.SignOut.title,
|
||||
message: L10n.Common.Alerts.SignOut.message,
|
||||
preferredStyle: .alert
|
||||
)
|
||||
|
||||
let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel, handler: nil)
|
||||
let signOutAction = UIAlertAction(title: L10n.Common.Alerts.SignOut.confirm, style: .destructive) { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
self.signout()
|
||||
}
|
||||
alertController.addAction(cancelAction)
|
||||
alertController.addAction(signOutAction)
|
||||
self.coordinator.present(
|
||||
scene: .alertController(alertController: alertController),
|
||||
from: self,
|
||||
transition: .alertController(animated: true, completion: nil)
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
deinit {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
let snapshot = self.viewModel.dataSource.snapshot()
|
||||
let sectionIds = snapshot.sectionIdentifiers
|
||||
guard indexPath.section < sectionIds.count else { return }
|
||||
let sectionIdentifier = sectionIds[indexPath.section]
|
||||
let items = snapshot.itemIdentifiers(inSection: sectionIdentifier)
|
||||
guard indexPath.row < items.count else { return }
|
||||
let item = items[indexPath.item]
|
||||
|
||||
switch item {
|
||||
case .boringZone:
|
||||
guard let url = viewModel.privacyURL else { break }
|
||||
coordinator.present(
|
||||
scene: .safari(url: url),
|
||||
from: self,
|
||||
transition: .safariPresent(animated: true, completion: nil)
|
||||
)
|
||||
case .spicyZone(let link):
|
||||
// clear media cache
|
||||
if link.title == L10n.Scene.Settings.Section.Spicyzone.clear {
|
||||
// clean image cache for AlamofireImage
|
||||
let diskBytes = ImageDownloader.defaultURLCache().currentDiskUsage
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: diskBytes %d", ((#file as NSString).lastPathComponent), #line, #function, diskBytes)
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: clean image cache", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
ImageDownloader.defaultURLCache().removeAllCachedResponses()
|
||||
let cleanedDiskBytes = ImageDownloader.defaultURLCache().currentDiskUsage
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: diskBytes %d", ((#file as NSString).lastPathComponent), #line, #function, cleanedDiskBytes)
|
||||
|
||||
// clean Kingfisher Cache
|
||||
KingfisherManager.shared.cache.clearDiskCache()
|
||||
}
|
||||
// logout
|
||||
if link.title == L10n.Scene.Settings.Section.Spicyzone.signout {
|
||||
alertToSignout()
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update setting into core data
|
||||
extension SettingsViewController {
|
||||
func updateTrigger(by who: String) {
|
||||
guard self.viewModel.triggerBy != who else { return }
|
||||
guard let setting = self.viewModel.setting.value else { return }
|
||||
|
||||
setting.update(triggerBy: who)
|
||||
// trigger to call `subscription` API with POST method
|
||||
// confirm the local data is correct even if request failed
|
||||
// The asynchronous execution is to solve the problem of dropped frames for animations.
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.viewModel.setting.value = setting
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
|
||||
if let alerts = settings.subscription?.first(where: { (s) -> Bool in
|
||||
return s.type == settings.triggerBy
|
||||
})?.alert {
|
||||
var alertValues = [Bool?]()
|
||||
alertValues.append(alerts.favourite?.boolValue)
|
||||
alertValues.append(alerts.follow?.boolValue)
|
||||
alertValues.append(alerts.reblog?.boolValue)
|
||||
alertValues.append(alerts.mention?.boolValue)
|
||||
|
||||
// need to update `alerts` to make update API with correct parameter
|
||||
switch title {
|
||||
case L10n.Scene.Settings.Section.Notifications.favorites:
|
||||
alertValues[0] = isOn
|
||||
alerts.favourite = NSNumber(booleanLiteral: isOn)
|
||||
case L10n.Scene.Settings.Section.Notifications.follows:
|
||||
alertValues[1] = isOn
|
||||
alerts.follow = NSNumber(booleanLiteral: isOn)
|
||||
case L10n.Scene.Settings.Section.Notifications.boosts:
|
||||
alertValues[2] = isOn
|
||||
alerts.reblog = NSNumber(booleanLiteral: isOn)
|
||||
case L10n.Scene.Settings.Section.Notifications.mentions:
|
||||
alertValues[3] = isOn
|
||||
alerts.mention = NSNumber(booleanLiteral: isOn)
|
||||
default: break
|
||||
}
|
||||
self.viewModel.updateSubscriptionSubject.send((triggerBy: triggerBy, values: alertValues))
|
||||
} else if let alertValues = self.viewModel.notificationDefaultValue[triggerBy] {
|
||||
self.viewModel.updateSubscriptionSubject.send((triggerBy: triggerBy, values: alertValues))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SettingsViewController: SettingsAppearanceTableViewCellDelegate {
|
||||
func settingsAppearanceCell(_ view: SettingsAppearanceTableViewCell, didSelect: SettingsItem.AppearanceMode) {
|
||||
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: .safari(url: URL(string: "https://github.com/tootsuite/mastodon")!),
|
||||
from: self,
|
||||
transition: .safariPresent(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, userID: box.userID)
|
||||
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
|
|
@ -0,0 +1,393 @@
|
|||
//
|
||||
// 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>()
|
||||
var updateDisposeBag = Set<AnyCancellable>()
|
||||
var createDisposeBag = 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, userID: box.userID)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
/// create a subscription when:
|
||||
/// - does not has one
|
||||
/// - does not find subscription for selected trigger when change trigger
|
||||
let createSubscriptionSubject = PassthroughSubject<(triggerBy: String, values: [Bool?]), Never>()
|
||||
|
||||
/// update a subscription when:
|
||||
/// - change switch for specified alerts
|
||||
let updateSubscriptionSubject = 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]
|
||||
}()
|
||||
|
||||
lazy var privacyURL: URL? = {
|
||||
guard let box = AppContext.shared.authenticationService.activeMastodonAuthenticationBox.value else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return Mastodon.API.privacyURL(domain: box.domain)
|
||||
}()
|
||||
|
||||
/// to store who trigger the notification.
|
||||
var triggerBy: String?
|
||||
|
||||
struct Input {
|
||||
}
|
||||
|
||||
struct Output {
|
||||
}
|
||||
|
||||
init(context: AppContext, coordinator: SceneCoordinator) {
|
||||
self.context = context
|
||||
self.coordinator = coordinator
|
||||
|
||||
super.init()
|
||||
}
|
||||
|
||||
func transform(input: Input?) -> Output? {
|
||||
typealias SubscriptionResponse = Mastodon.Response.Content<Mastodon.Entity.Subscription>
|
||||
createSubscriptionSubject
|
||||
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
|
||||
.sink { _ in
|
||||
} receiveValue: { [weak self] (arg) in
|
||||
let (triggerBy, values) = arg
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
guard let activeMastodonAuthenticationBox =
|
||||
self.context.authenticationService.activeMastodonAuthenticationBox.value else {
|
||||
return
|
||||
}
|
||||
guard values.count >= 4 else {
|
||||
return
|
||||
}
|
||||
|
||||
self.createDisposeBag.removeAll()
|
||||
typealias Query = Mastodon.API.Subscriptions.CreateSubscriptionQuery
|
||||
let domain = activeMastodonAuthenticationBox.domain
|
||||
let query = Query(
|
||||
// FIXME: to replace the correct endpoint, p256dh, auth
|
||||
endpoint: "http://www.google.com",
|
||||
p256dh: "BLQELIDm-6b9Bl07YrEuXJ4BL_YBVQ0dvt9NQGGJxIQidJWHPNa9YrouvcQ9d7_MqzvGS9Alz60SZNCG3qfpk=",
|
||||
auth: "4vQK-SvRAN5eo-8ASlrwA==",
|
||||
favourite: values[0],
|
||||
follow: values[1],
|
||||
reblog: values[2],
|
||||
mention: values[3],
|
||||
poll: nil
|
||||
)
|
||||
self.context.apiService.changeSubscription(
|
||||
domain: domain,
|
||||
mastodonAuthenticationBox: activeMastodonAuthenticationBox,
|
||||
query: query,
|
||||
triggerBy: triggerBy,
|
||||
userID: activeMastodonAuthenticationBox.userID
|
||||
)
|
||||
.sink { (_) in
|
||||
} receiveValue: { (_) in
|
||||
}
|
||||
.store(in: &self.createDisposeBag)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
updateSubscriptionSubject
|
||||
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
|
||||
.sink { _ in
|
||||
} receiveValue: { [weak self] (arg) in
|
||||
let (triggerBy, values) = arg
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
guard let activeMastodonAuthenticationBox =
|
||||
self.context.authenticationService.activeMastodonAuthenticationBox.value else {
|
||||
return
|
||||
}
|
||||
guard values.count >= 4 else {
|
||||
return
|
||||
}
|
||||
|
||||
self.updateDisposeBag.removeAll()
|
||||
typealias Query = Mastodon.API.Subscriptions.UpdateSubscriptionQuery
|
||||
let domain = activeMastodonAuthenticationBox.domain
|
||||
let query = Query(
|
||||
favourite: values[0],
|
||||
follow: values[1],
|
||||
reblog: values[2],
|
||||
mention: values[3],
|
||||
poll: nil)
|
||||
self.context.apiService.updateSubscription(
|
||||
domain: domain,
|
||||
mastodonAuthenticationBox: activeMastodonAuthenticationBox,
|
||||
query: query,
|
||||
triggerBy: triggerBy,
|
||||
userID: activeMastodonAuthenticationBox.userID
|
||||
)
|
||||
.sink { (_) in
|
||||
} receiveValue: { (_) in
|
||||
}
|
||||
.store(in: &self.updateDisposeBag)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
// build data for table view
|
||||
buildDataSource()
|
||||
|
||||
// request subsription data for updating or initialization
|
||||
requestSubscription()
|
||||
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?.boolValue)
|
||||
items.append(alerts.follow?.boolValue)
|
||||
items.append(alerts.reblog?.boolValue)
|
||||
items.append(alerts.mention?.boolValue)
|
||||
switches = items
|
||||
} else if let triggerBy = settings?.triggerBy,
|
||||
let values = self.notificationDefaultValue[triggerBy] {
|
||||
switches = 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 {
|
||||
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 {
|
||||
let item = SettingsItem.spicyZone(item: SettingsItem.Link(title: l, color: .systemRed))
|
||||
spicyLinkItems.append(item)
|
||||
}
|
||||
let spicySection = SettingsSection.spicyZone(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.sink { [weak self] (settings) in
|
||||
guard let self = self else { return }
|
||||
self.processDataSource(settings)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
private func requestSubscription() {
|
||||
setting.sink { [weak self] (settings) in
|
||||
guard let self = self else { return }
|
||||
guard settings != nil else { return }
|
||||
guard self.triggerBy != settings?.triggerBy else { return }
|
||||
self.triggerBy = settings?.triggerBy
|
||||
|
||||
var switches: [Bool?]?
|
||||
var who: String?
|
||||
if let alerts = settings?.subscription?.first(where: { (s) -> Bool in
|
||||
return s.type == settings?.triggerBy
|
||||
})?.alert {
|
||||
var items = [Bool?]()
|
||||
items.append(alerts.favourite?.boolValue)
|
||||
items.append(alerts.follow?.boolValue)
|
||||
items.append(alerts.reblog?.boolValue)
|
||||
items.append(alerts.mention?.boolValue)
|
||||
switches = items
|
||||
who = settings?.triggerBy
|
||||
} else if let triggerBy = settings?.triggerBy,
|
||||
let values = self.notificationDefaultValue[triggerBy] {
|
||||
switches = values
|
||||
who = triggerBy
|
||||
} else {
|
||||
// fallback a default value
|
||||
let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone
|
||||
switches = self.notificationDefaultValue[anyone]
|
||||
who = anyone
|
||||
}
|
||||
|
||||
// should create a subscription whenever change trigger
|
||||
if let values = switches, let triggerBy = who {
|
||||
self.createSubscriptionSubject.send((triggerBy: triggerBy, values: values))
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else {
|
||||
return
|
||||
}
|
||||
let domain = activeMastodonAuthenticationBox.domain
|
||||
let userId = activeMastodonAuthenticationBox.userID
|
||||
|
||||
do {
|
||||
try fetchResultsController.performFetch()
|
||||
if nil == fetchResultsController.fetchedObjects?.first {
|
||||
let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone
|
||||
setting.value = self.context.apiService.createSettingIfNeed(domain: domain,
|
||||
userId: userId,
|
||||
triggerBy: anyone)
|
||||
} else {
|
||||
setting.value = fetchResultsController.fetchedObjects?.first
|
||||
}
|
||||
} catch {
|
||||
assertionFailure(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
}
|
||||
}
|
||||
|
||||
// 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(title: 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)
|
||||
}
|
|
@ -0,0 +1,205 @@
|
|||
//
|
||||
// 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.Label.secondary.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.Label.highlight.color
|
||||
} else {
|
||||
checkBox.imageView?.tintColor = Asset.Colors.Label.secondary.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
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
guard let delegate = self.delegate else { return }
|
||||
delegate.settingsAppearanceCell(self, didSelect: appearance)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -0,0 +1,63 @@
|
|||
//
|
||||
// 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)
|
||||
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
|
||||
accessoryView = switchButton
|
||||
|
||||
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
|
||||
}
|
||||
}
|
|
@ -0,0 +1,64 @@
|
|||
//
|
||||
// SettingsSectionHeader.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by ihugo on 2021/4/8.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
struct GroupedTableViewConstraints {
|
||||
static let topMargin: CGFloat = 40
|
||||
static let bottomMargin: CGFloat = 10
|
||||
}
|
||||
|
||||
/// 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: GroupedTableViewConstraints.topMargin,
|
||||
left: 0,
|
||||
bottom: GroupedTableViewConstraints.bottomMargin,
|
||||
right: 0
|
||||
)
|
||||
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.readableContentGuide.leadingAnchor),
|
||||
stackView.trailingAnchor.constraint(lessThanOrEqualTo: self.readableContentGuide.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()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,163 @@
|
|||
//
|
||||
// APIService+Settings.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by ihugo on 2021/4/9.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
import Foundation
|
||||
import MastodonSDK
|
||||
|
||||
extension APIService {
|
||||
|
||||
func subscription(
|
||||
domain: String,
|
||||
userID: String,
|
||||
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
|
||||
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Subscription>, Error> {
|
||||
let authorization = mastodonAuthenticationBox.userAuthorization
|
||||
|
||||
let findSettings: Setting? = {
|
||||
let request = Setting.sortedFetchRequest
|
||||
request.predicate = Setting.predicate(domain: domain, userID: userID)
|
||||
request.fetchLimit = 1
|
||||
request.returnsObjectsAsFaults = false
|
||||
do {
|
||||
return try self.backgroundManagedObjectContext.fetch(request).first
|
||||
} catch {
|
||||
assertionFailure(error.localizedDescription)
|
||||
return nil
|
||||
}
|
||||
}()
|
||||
let triggerBy = findSettings?.triggerBy ?? "anyone"
|
||||
let setting = self.createSettingIfNeed(
|
||||
domain: domain,
|
||||
userId: userID,
|
||||
triggerBy: triggerBy
|
||||
)
|
||||
return Mastodon.API.Subscriptions.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,
|
||||
triggerBy: triggerBy,
|
||||
setting: setting)
|
||||
}
|
||||
.setFailureType(to: Error.self)
|
||||
.map { _ in return response }
|
||||
.eraseToAnyPublisher()
|
||||
}.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func changeSubscription(
|
||||
domain: String,
|
||||
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox,
|
||||
query: Mastodon.API.Subscriptions.CreateSubscriptionQuery,
|
||||
triggerBy: String,
|
||||
userID: String
|
||||
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Subscription>, Error> {
|
||||
let authorization = mastodonAuthenticationBox.userAuthorization
|
||||
|
||||
let setting = self.createSettingIfNeed(domain: domain,
|
||||
userId: userID,
|
||||
triggerBy: triggerBy)
|
||||
return Mastodon.API.Subscriptions.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,
|
||||
setting: setting
|
||||
)
|
||||
}
|
||||
.setFailureType(to: Error.self)
|
||||
.map { _ in return response }
|
||||
.eraseToAnyPublisher()
|
||||
}.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func updateSubscription(
|
||||
domain: String,
|
||||
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox,
|
||||
query: Mastodon.API.Subscriptions.UpdateSubscriptionQuery,
|
||||
triggerBy: String,
|
||||
userID: String
|
||||
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Subscription>, Error> {
|
||||
let authorization = mastodonAuthenticationBox.userAuthorization
|
||||
|
||||
let setting = self.createSettingIfNeed(domain: domain,
|
||||
userId: userID,
|
||||
triggerBy: triggerBy)
|
||||
|
||||
return Mastodon.API.Subscriptions.updateSubscription(
|
||||
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,
|
||||
setting: setting
|
||||
)
|
||||
}
|
||||
.setFailureType(to: Error.self)
|
||||
.map { _ in return response }
|
||||
.eraseToAnyPublisher()
|
||||
}.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func createSettingIfNeed(domain: String, userId: String, triggerBy: String) -> Setting {
|
||||
// create setting entity if possible
|
||||
let oldSetting: Setting? = {
|
||||
let request = Setting.sortedFetchRequest
|
||||
request.predicate = Setting.predicate(domain: domain, userID: userId)
|
||||
request.fetchLimit = 1
|
||||
request.returnsObjectsAsFaults = false
|
||||
do {
|
||||
return try backgroundManagedObjectContext.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: triggerBy,
|
||||
domain: domain,
|
||||
userID: userId)
|
||||
(setting, _) = APIService.CoreData.createOrMergeSetting(
|
||||
into: backgroundManagedObjectContext,
|
||||
domain: domain,
|
||||
userID: userId,
|
||||
property: property
|
||||
)
|
||||
}
|
||||
return setting
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
//
|
||||
// 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,
|
||||
userID: String,
|
||||
property: Setting.Property
|
||||
) -> (Subscription: Setting, isCreated: Bool) {
|
||||
let oldSetting: Setting? = {
|
||||
let request = Setting.sortedFetchRequest
|
||||
request.predicate = Setting.predicate(domain: property.domain, userID: userID)
|
||||
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,
|
||||
setting: Setting
|
||||
) -> (Subscription: Subscription, isCreated: Bool) {
|
||||
let oldSubscription: Subscription? = {
|
||||
let request = Subscription.sortedFetchRequest
|
||||
request.predicate = Subscription.predicate(type: triggerBy)
|
||||
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
|
||||
)
|
||||
let alertEntity = entity.alerts
|
||||
let alert = SubscriptionAlerts.Property(
|
||||
favourite: alertEntity.favouriteNumber,
|
||||
follow: alertEntity.followNumber,
|
||||
mention: alertEntity.mentionNumber,
|
||||
poll: alertEntity.pollNumber,
|
||||
reblog: alertEntity.reblogNumber
|
||||
)
|
||||
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 || oldSubscription.hasChanges {
|
||||
// don't expand subscription if add existed subscription
|
||||
//setting.mutableSetValue(forKey: #keyPath(Setting.subscription)).add(oldSubscription)
|
||||
oldSubscription.didUpdate(at: Date())
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -0,0 +1,226 @@
|
|||
//
|
||||
// File.swift
|
||||
//
|
||||
//
|
||||
// Created by ihugo on 2021/4/9.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
extension Mastodon.API.Subscriptions {
|
||||
|
||||
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 `Subscription` 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()
|
||||
}
|
||||
|
||||
/// Subscribe to push notifications
|
||||
///
|
||||
/// Add a Web Push API subscription to receive notifications. Each access token can have one push subscription. If you create a new subscription, the old subscription is deleted.
|
||||
///
|
||||
/// - 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 `Subscription` 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()
|
||||
}
|
||||
|
||||
/// Change types of notifications
|
||||
///
|
||||
/// Updates the current push subscription. Only the data part can be updated. To change fundamentals, a new subscription must be created instead.
|
||||
///
|
||||
/// - 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 `Subscription` nested in the response
|
||||
public static func updateSubscription(
|
||||
session: URLSession,
|
||||
domain: String,
|
||||
authorization: Mastodon.API.OAuth.Authorization,
|
||||
query: UpdateSubscriptionQuery
|
||||
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Subscription>, Error> {
|
||||
let request = Mastodon.API.put(
|
||||
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.Subscriptions {
|
||||
public struct CreateSubscriptionQuery: Codable, PostQuery {
|
||||
let endpoint: String
|
||||
let p256dh: String
|
||||
let auth: String
|
||||
let favourite: Bool?
|
||||
let follow: Bool?
|
||||
let reblog: Bool?
|
||||
let mention: Bool?
|
||||
let poll: Bool?
|
||||
|
||||
var queryItems: [URLQueryItem]? {
|
||||
var items = [URLQueryItem]()
|
||||
|
||||
items.append(URLQueryItem(name: "subscription[endpoint]", value: endpoint))
|
||||
items.append(URLQueryItem(name: "subscription[keys][p256dh]", value: p256dh))
|
||||
items.append(URLQueryItem(name: "subscription[keys][auth]", value: auth))
|
||||
|
||||
if let followValue = follow?.queryItemValue {
|
||||
let followItem = URLQueryItem(name: "data[alerts][follow]", value: followValue)
|
||||
items.append(followItem)
|
||||
}
|
||||
|
||||
if let favouriteValue = favourite?.queryItemValue {
|
||||
let favouriteItem = URLQueryItem(name: "data[alerts][favourite]", value: favouriteValue)
|
||||
items.append(favouriteItem)
|
||||
}
|
||||
|
||||
if let reblogValue = reblog?.queryItemValue {
|
||||
let reblogItem = URLQueryItem(name: "data[alerts][reblog]", value: reblogValue)
|
||||
items.append(reblogItem)
|
||||
}
|
||||
|
||||
if let mentionValue = mention?.queryItemValue {
|
||||
let mentionItem = URLQueryItem(name: "data[alerts][mention]", value: mentionValue)
|
||||
items.append(mentionItem)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
public init(
|
||||
endpoint: String,
|
||||
p256dh: String,
|
||||
auth: String,
|
||||
favourite: Bool?,
|
||||
follow: Bool?,
|
||||
reblog: Bool?,
|
||||
mention: Bool?,
|
||||
poll: Bool?
|
||||
) {
|
||||
self.endpoint = endpoint
|
||||
self.p256dh = p256dh
|
||||
self.auth = auth
|
||||
self.favourite = favourite
|
||||
self.follow = follow
|
||||
self.reblog = reblog
|
||||
self.mention = mention
|
||||
self.poll = poll
|
||||
}
|
||||
}
|
||||
|
||||
public struct UpdateSubscriptionQuery: Codable, PutQuery {
|
||||
let favourite: Bool?
|
||||
let follow: Bool?
|
||||
let reblog: Bool?
|
||||
let mention: Bool?
|
||||
let poll: Bool?
|
||||
|
||||
var queryItems: [URLQueryItem]? {
|
||||
var items = [URLQueryItem]()
|
||||
|
||||
if let followValue = follow?.queryItemValue {
|
||||
let followItem = URLQueryItem(name: "data[alerts][follow]", value: followValue)
|
||||
items.append(followItem)
|
||||
}
|
||||
|
||||
if let favouriteValue = favourite?.queryItemValue {
|
||||
let favouriteItem = URLQueryItem(name: "data[alerts][favourite]", value: favouriteValue)
|
||||
items.append(favouriteItem)
|
||||
}
|
||||
|
||||
if let reblogValue = reblog?.queryItemValue {
|
||||
let reblogItem = URLQueryItem(name: "data[alerts][reblog]", value: reblogValue)
|
||||
items.append(reblogItem)
|
||||
}
|
||||
|
||||
if let mentionValue = mention?.queryItemValue {
|
||||
let mentionItem = URLQueryItem(name: "data[alerts][mention]", value: mentionValue)
|
||||
items.append(mentionItem)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
public init(
|
||||
favourite: Bool?,
|
||||
follow: Bool?,
|
||||
reblog: Bool?,
|
||||
mention: Bool?,
|
||||
poll: Bool?
|
||||
) {
|
||||
self.favourite = favourite
|
||||
self.follow = follow
|
||||
self.reblog = reblog
|
||||
self.mention = mention
|
||||
self.poll = poll
|
||||
}
|
||||
}
|
||||
}
|
|
@ -115,6 +115,7 @@ extension Mastodon.API {
|
|||
public enum Trends { }
|
||||
public enum Suggestions { }
|
||||
public enum Notifications { }
|
||||
public enum Subscriptions { }
|
||||
}
|
||||
|
||||
extension Mastodon.API {
|
||||
|
|
|
@ -0,0 +1,77 @@
|
|||
//
|
||||
// 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?
|
||||
|
||||
public var followNumber: NSNumber? {
|
||||
guard let value = follow else { return nil }
|
||||
return NSNumber(booleanLiteral: value)
|
||||
}
|
||||
public var favouriteNumber: NSNumber? {
|
||||
guard let value = favourite else { return nil }
|
||||
return NSNumber(booleanLiteral: value)
|
||||
}
|
||||
public var reblogNumber: NSNumber? {
|
||||
guard let value = reblog else { return nil }
|
||||
return NSNumber(booleanLiteral: value)
|
||||
}
|
||||
public var mentionNumber: NSNumber? {
|
||||
guard let value = mention else { return nil }
|
||||
return NSNumber(booleanLiteral: value)
|
||||
}
|
||||
public var pollNumber: NSNumber? {
|
||||
guard let value = poll else { return nil }
|
||||
return NSNumber(booleanLiteral: value)
|
||||
}
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
var id = try? container.decode(String.self, forKey: .id)
|
||||
if nil == id, let numId = try? container.decode(Int.self, forKey: .id) {
|
||||
id = String(numId)
|
||||
}
|
||||
self.id = id ?? ""
|
||||
|
||||
endpoint = try container.decode(String.self, forKey: .endpoint)
|
||||
alerts = try container.decode(Alerts.self, forKey: .alerts)
|
||||
serverKey = try container.decode(String.self, forKey: .serverKey)
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue