├── .gitignore ├── Sources └── SwiftyPress │ ├── Resources │ ├── Media.xcassets │ │ └── Contents.json │ ├── en.lproj │ │ └── Localizable.strings │ └── ar.lproj │ │ └── Localizable.strings │ ├── Views │ └── UIKit │ │ ├── Controls │ │ ├── Themed │ │ │ ├── ThemedSwitch.swift │ │ │ ├── ThemedImageView.swift │ │ │ ├── ThemedPageControl.swift │ │ │ ├── ThemedProgressView.swift │ │ │ ├── ThemedSegmentedControl.swift │ │ │ ├── ThemedTextField.swift │ │ │ ├── ThemedTextView.swift │ │ │ ├── ThemedView.swift │ │ │ ├── ThemedButton.swift │ │ │ └── ThemedLabel.swift │ │ ├── Layouts │ │ │ ├── ScrollableFlowLayout.swift │ │ │ ├── MultiRowLayout.swift │ │ │ └── SnapPagingLayout.swift │ │ ├── DataViews │ │ │ ├── TermsDataView │ │ │ │ ├── TermsDataViewModel.swift │ │ │ │ ├── TermsDataViewDelegate.swift │ │ │ │ ├── Cells │ │ │ │ │ └── TermTableViewCell.swift │ │ │ │ └── TermsDataViewAdapter.swift │ │ │ └── PostsDataView │ │ │ │ ├── PostsDataViewModel.swift │ │ │ │ ├── Cells │ │ │ │ ├── SimplePostTableViewCell.swift │ │ │ │ ├── LatestPostCollectionViewCell.swift │ │ │ │ ├── PostTableViewCell.swift │ │ │ │ ├── PickedPostCollectionViewCell.swift │ │ │ │ └── PopularPostCollectionViewCell.swift │ │ │ │ ├── PostsDataViewDelegate.swift │ │ │ │ └── PostsDataViewAdapter.swift │ │ ├── SocialButton.swift │ │ └── EmptyPlaceholderView.swift │ │ └── Extensions │ │ ├── UIViewController.swift │ │ ├── UIImage.swift │ │ └── UIImageView.swift │ ├── Extensions │ ├── DispatchQueue.swift │ ├── Bundle.swift │ ├── JSONDecoder.swift │ ├── Dictionary.swift │ ├── Realm.swift │ └── LogRepository.swift │ ├── Models │ ├── Dateable.swift │ └── ChangeResult.swift │ ├── Data │ ├── Models │ │ ├── SyncActivity.swift │ │ └── SeedPayload.swift │ ├── DataAPI.swift │ └── Services │ │ ├── DataFileSeed.swift │ │ └── DataNetworkService.swift │ ├── Repositories │ ├── Media │ │ ├── MediaAPI.swift │ │ ├── Models │ │ │ ├── MediaRealmObject.swift │ │ │ └── Media.swift │ │ ├── MediaRepository.swift │ │ └── Services │ │ │ ├── MediaFileCache.swift │ │ │ ├── MediaNetworkService.swift │ │ │ └── MediaRealmCache.swift │ ├── Taxonomy │ │ ├── TaxonomyAPI.swift │ │ ├── Models │ │ │ ├── TermRealmObject.swift │ │ │ ├── Term.swift │ │ │ └── Taxonomy.swift │ │ ├── Services │ │ │ ├── TaxonomyFileCache.swift │ │ │ └── TaxonomyRealmCache.swift │ │ └── TaxonomyRepository.swift │ ├── Post │ │ ├── Models │ │ │ ├── ExtendedPost.swift │ │ │ ├── Post.swift │ │ │ └── PostRealmObject.swift │ │ ├── PostAPI.swift │ │ └── Services │ │ │ └── PostNetworkService.swift │ ├── Author │ │ ├── Models │ │ │ ├── AuthorRealmObject.swift │ │ │ └── Author.swift │ │ ├── Services │ │ │ ├── AuthorFileCache.swift │ │ │ ├── AuthorNetworkService.swift │ │ │ └── AuthorRealmCache.swift │ │ ├── AuthorAPI.swift │ │ └── AuthorRepository.swift │ └── Favorite │ │ └── FavoriteRepository.swift │ ├── Preferences │ ├── Constants │ │ ├── ConstantsAPI.swift │ │ ├── Constants.swift │ │ └── Services │ │ │ └── ConstantsStaticService.swift │ └── Shared │ │ └── Preferences.swift │ ├── Errors │ ├── SwiftyPressError+Network.swift │ └── SwiftyPressError.swift │ ├── Styles │ └── Theme.swift │ ├── Enums │ └── Social.swift │ └── SwiftyPressCore.swift ├── .swiftpm └── xcode │ ├── package.xcworkspace │ └── contents.xcworkspacedata │ └── xcshareddata │ └── xcschemes │ └── SwiftyPress.xcscheme ├── Tests ├── SwiftyPressModelTests │ ├── Resources │ │ ├── Media │ │ │ └── MediaTests.json │ │ ├── Author │ │ │ └── AuthorTests.json │ │ └── Post │ │ │ └── PostTests2.json │ ├── Models │ │ ├── MediaTests.swift │ │ └── AuthorTests.swift │ └── TestUtilities.swift └── SwiftyPressTests │ ├── Repositories │ ├── DataRepositoryTests.swift │ ├── FavoriteRepositoryTests.swift │ └── MediaRepositoryTests.swift │ ├── TestCase.swift │ ├── Mocks │ └── DataJSONSeed.swift │ ├── TestUtilities.swift │ └── TestCore.swift ├── LICENSE ├── .swiftlint.yml ├── README.md ├── Package.resolved └── Package.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Resources/Media.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Views/UIKit/Controls/Themed/ThemedSwitch.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThemedSwitch.swift 3 | // ZamzamKit iOS 4 | // 5 | // Created by Basem Emara on 2019-05-08. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | #if os(iOS) 10 | import UIKit 11 | 12 | open class ThemedSwitch: UISwitch {} 13 | #endif 14 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Views/UIKit/Controls/Themed/ThemedImageView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThemedImage.swift 3 | // ZamzamKit iOS 4 | // 5 | // Created by Basem Emara on 2019-05-08. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | #if os(iOS) 10 | import UIKit 11 | 12 | open class ThemedImageView: UIImageView {} 13 | #endif 14 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Extensions/DispatchQueue.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DispatchQueue.swift 3 | // SwiftyPress 4 | // 5 | // Created by Basem Emara on 2019-05-10. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension DispatchQueue { 12 | static let labelPrefix = "io.zamzam.SwiftyPress" 13 | } 14 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Models/Dateable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Dateable.swift 3 | // SwiftPress 4 | // 5 | // Created by Basem Emara on 2018-05-29. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | import Foundation.NSDate 10 | 11 | public protocol Dateable { 12 | var createdAt: Date { get } 13 | var modifiedAt: Date { get } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Views/UIKit/Controls/Themed/ThemedPageControl.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThemedPageControl.swift 3 | // ZamzamKit iOS 4 | // 5 | // Created by Basem Emara on 2019-05-08. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | #if os(iOS) 10 | import UIKit 11 | 12 | open class ThemedPageControl: UIPageControl {} 13 | #endif 14 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Views/UIKit/Controls/Themed/ThemedProgressView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThemedProgressView.swift 3 | // ZamzamKit iOS 4 | // 5 | // Created by Basem Emara on 2019-07-15. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | #if os(iOS) 10 | import UIKit 11 | 12 | open class ThemedProgressView: UIProgressView {} 13 | #endif 14 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Views/UIKit/Controls/Themed/ThemedSegmentedControl.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThemedSegmentedControl.swift 3 | // ZamzamKit iOS 4 | // 5 | // Created by Basem Emara on 2019-05-08. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | #if os(iOS) 10 | import UIKit 11 | 12 | open class ThemedSegmentedControl: UISegmentedControl {} 13 | #endif 14 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Views/UIKit/Controls/Themed/ThemedTextField.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThemedTextField.swift 3 | // ZamzamKit iOS 4 | // 5 | // Created by Basem Emara on 2019-05-08. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | #if os(iOS) 10 | import UIKit 11 | import ZamzamUI 12 | 13 | open class ThemedTextField: NextResponderTextField {} 14 | #endif 15 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Views/UIKit/Controls/Themed/ThemedTextView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThemedTextView.swift 3 | // ZamzamKit iOS 4 | // 5 | // Created by Basem Emara on 2019-05-08. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | #if os(iOS) 10 | import UIKit 11 | import ZamzamUI 12 | 13 | open class ThemedTextView: UITextView {} 14 | open class ThemedLabelView: UILabelView {} 15 | #endif 16 | -------------------------------------------------------------------------------- /Tests/SwiftyPressModelTests/Resources/Media/MediaTests.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 41346, 3 | "link": "https://staging1.basememara.com/wp-content/uploads/2018/04/Clean-Architecture-Cycle-2.png", 4 | "width": 500, 5 | "height": 518, 6 | "thumbnail_link": "https://staging1.basememara.com/wp-content/uploads/2018/04/Clean-Architecture-Cycle-2-500x518.png", 7 | "thumbnail_width": 500, 8 | "thumbnail_height": 518 9 | } 10 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Extensions/Bundle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Bundle.swift 3 | // SwiftyPress 4 | // 5 | // Created by Basem Emara on 2018-06-12. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | import Foundation.NSBundle 10 | 11 | public extension Bundle { 12 | 13 | /// A representation of the code and resources stored in SwiftyPress bundle directory on disk. 14 | static let swiftyPress: Bundle = .module 15 | } 16 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Views/UIKit/Controls/Themed/ThemedView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThemedView.swift 3 | // ZamzamKit iOS 4 | // 5 | // Created by Basem Emara on 2019-05-08. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | #if os(iOS) 10 | import UIKit 11 | 12 | open class ThemedView: UIView {} 13 | open class ThemedTintView: UIView {} 14 | open class ThemedHeaderView: UIView {} 15 | open class ThemedSeparator: UIView {} 16 | open class ThemedErrorView: ThemedView {} 17 | #endif 18 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Views/UIKit/Controls/Layouts/ScrollableFlowLayout.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScrollableFlowLayout.swift 3 | // SwiftyPress iOS 4 | // 5 | // Created by Basem Emara on 2018-10-03. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | #if os(iOS) 10 | import UIKit 11 | 12 | public protocol ScrollableFlowLayout { 13 | func willBeginDragging() 14 | func willEndDragging(withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) 15 | } 16 | #endif 17 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Data/Models/SyncActivity.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SyncActivity.swift 3 | // SwiftyPress 4 | // 5 | // Created by Basem Emara on 2018-10-17. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | import Foundation.NSDate 10 | import RealmSwift 11 | 12 | @objcMembers 13 | class SyncActivity: Object { 14 | dynamic var type: String = "" 15 | dynamic var lastFetchedAt: Date? 16 | 17 | override static func primaryKey() -> String? { 18 | "type" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Views/UIKit/Controls/Themed/ThemedButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThemedButton.swift 3 | // ZamzamKit iOS 4 | // 5 | // Created by Basem Emara on 2019-05-08. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | #if os(iOS) 10 | import UIKit 11 | 12 | open class ThemedButton: UIButton {} 13 | open class ThemedPrimaryButton: UIButton {} 14 | open class ThemedSecondaryButton: UIButton {} 15 | open class ThemedLabelButton: UIButton {} 16 | open class ThemedImageButton: UIButton {} 17 | #endif 18 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Views/UIKit/Controls/DataViews/TermsDataView/TermsDataViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TagsDataViewModel.swift 3 | // Basem Emara 4 | // 5 | // Created by Basem Emara on 2018-06-25. 6 | // Copyright © 2018 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | public struct TermsDataViewModel: Identifiable, Equatable { 10 | public let id: Int 11 | public let name: String 12 | public let count: String 13 | public let taxonomy: Taxonomy 14 | 15 | public init(id: Int, name: String, count: String, taxonomy: Taxonomy) { 16 | self.id = id 17 | self.name = name 18 | self.count = count 19 | self.taxonomy = taxonomy 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Views/UIKit/Controls/DataViews/TermsDataView/TermsDataViewDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TagsDataViewDelegate.swift 3 | // Basem Emara 4 | // 5 | // Created by Basem Emara on 2018-06-25. 6 | // Copyright © 2018 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | #if os(iOS) 10 | import UIKit 11 | import ZamzamUI 12 | 13 | public protocol TermsDataViewDelegate: AnyObject { 14 | func termsDataView(didSelect model: TermsDataViewModel, at indexPath: IndexPath, from dataView: DataViewable) 15 | func termsDataViewDidReloadData() 16 | } 17 | 18 | // Optional conformance 19 | public extension TermsDataViewDelegate { 20 | func termsDataViewDidReloadData() {} 21 | } 22 | #endif 23 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Extensions/JSONDecoder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JSONDecoder.swift 3 | // SwiftyPress 4 | // 5 | // Created by Basem Emara on 2018-06-12. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import ZamzamCore 11 | 12 | public extension JSONDecoder { 13 | 14 | static let `default` = JSONDecoder().apply { 15 | $0.dateDecodingStrategy = .formatted( 16 | DateFormatter(iso8601Format: "yyyy-MM-dd'T'HH:mm:ss") 17 | ) 18 | 19 | // TODO: One day hopefully use this 20 | // https://bugs.swift.org/browse/SR-5823 21 | /*ISO8601DateFormatter().with { 22 | $0.formatOptions = [.withInternetDateTime] 23 | }*/ 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Views/UIKit/Controls/Themed/ThemedLabel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThemedLabel.swift 3 | // ZamzamKit iOS 4 | // 5 | // Created by Basem Emara on 2019-05-08. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | #if os(iOS) 10 | import UIKit 11 | 12 | open class ThemedLabel: UILabel {} 13 | open class ThemedHeadline: UILabel {} 14 | open class ThemedSubhead: UILabel {} 15 | open class ThemedCaption: UILabel {} 16 | open class ThemedFootnote: UILabel {} 17 | open class ThemedTintLabel: UILabel {} 18 | open class ThemedDangerLabel: UILabel {} 19 | open class ThemedSuccessLabel: UILabel {} 20 | open class ThemedWarningLabel: UILabel {} 21 | open class ThemedLightLabel: UILabel {} 22 | open class ThemedDarkLabel: UILabel {} 23 | #endif 24 | -------------------------------------------------------------------------------- /Tests/SwiftyPressTests/Repositories/DataRepositoryTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DataRepositoryTests.swift 3 | // SwiftyPress 4 | // 5 | // Created by Basem Emara on 2018-06-12. 6 | // 7 | 8 | #if !os(watchOS) 9 | import XCTest 10 | import SwiftyPress 11 | 12 | final class DataRepositoryTests: TestCase { 13 | private lazy var dataRepository = core.dataRepository() 14 | 15 | func testFetch() { 16 | // Given 17 | let promise = expectation(description: #function) 18 | 19 | // When 20 | dataRepository.fetch { 21 | defer { promise.fulfill() } 22 | 23 | // Then 24 | XCTAssertNil($0.error, $0.error.debugDescription) 25 | } 26 | 27 | waitForExpectations(timeout: 10, handler: nil) 28 | } 29 | } 30 | #endif 31 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Repositories/Media/MediaAPI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MediaAPI.swift 3 | // SwiftyPress 4 | // 5 | // Created by Basem Emara on 2018-06-04. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | // MARK: - Services 10 | 11 | public protocol MediaService { 12 | func fetch(id: Int, completion: @escaping (Result) -> Void) 13 | } 14 | 15 | // MARK: - Cache 16 | 17 | public protocol MediaCache { 18 | func fetch(id: Int, completion: @escaping (Result) -> Void) 19 | func fetch(ids: Set, completion: @escaping (Result<[Media], SwiftyPressError>) -> Void) 20 | func createOrUpdate(_ request: Media, completion: @escaping (Result) -> Void) 21 | } 22 | 23 | // MARK: - Namespace 24 | 25 | public enum MediaAPI {} 26 | -------------------------------------------------------------------------------- /Tests/SwiftyPressModelTests/Resources/Author/AuthorTests.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 2, 3 | "name": "Basem Emara", 4 | "link": "https://staging1.basememara.com", 5 | "avatar": "https://secure.gravatar.com/avatar/8def0d36f56d3e6720a44e41bf6f9a71?s=96&d=mm&r=g", 6 | "description": "Basem is a mobile and software IT professional with over 12 years of experience as an architect, developer, and consultant for dozens of projects that span over various industries for Fortune 500 enterprises, government agencies, and startups. In 2014, Basem brought his vast knowledge and experiences to Swift and helped pioneer the language to build scalable enterprise iOS & watchOS apps, later providing mentorship courses at https://iosmentor.io.", 7 | "created": "2015-02-02T03:39:52", 8 | "modified": "2018-10-06T14:43:53" 9 | } 10 | -------------------------------------------------------------------------------- /Tests/SwiftyPressTests/TestCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestCase.swift 3 | // SwiftyPress 4 | // 5 | // Created by Basem Emara on 2018-06-12. 6 | // 7 | 8 | #if !os(watchOS) 9 | import XCTest 10 | import ZamzamCore 11 | @testable import SwiftyPress 12 | 13 | class TestCase: XCTestCase { 14 | private lazy var dataRepository = core.dataRepository() 15 | private lazy var preferences = core.preferences() 16 | 17 | lazy var core: SwiftyPressCore = TestCore() 18 | 19 | override func setUp() { 20 | super.setUp() 21 | 22 | // Apple bug: doesn't work when running tests in batches 23 | // https://bugs.swift.org/browse/SR-906 24 | continueAfterFailure = false 25 | 26 | // Clear previous 27 | dataRepository.resetCache(for: preferences.userID ?? 0) 28 | preferences.removeAll() 29 | 30 | // Setup database 31 | dataRepository.configure() 32 | } 33 | } 34 | #endif 35 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Preferences/Constants/ConstantsAPI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ConstantsAPI.swift 3 | // SwiftyPress iOS 4 | // 5 | // Created by Basem Emara on 2018-10-03. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | import Foundation.NSURL 10 | import ZamzamCore 11 | 12 | public protocol ConstantsService { 13 | var isDebug: Bool { get } 14 | var itunesName: String { get } 15 | var itunesID: String { get } 16 | var baseURL: URL { get } 17 | var baseREST: String { get } 18 | var wpREST: String { get } 19 | var email: String { get } 20 | var privacyURL: String { get } 21 | var disclaimerURL: String? { get } 22 | var styleSheet: String { get } 23 | var googleAnalyticsID: String? { get } 24 | var featuredCategoryID: Int { get } 25 | var defaultFetchModifiedLimit: Int { get } 26 | var taxonomies: [String] { get } 27 | var postMetaKeys: [String] { get } 28 | var minLogLevel: LogAPI.Level { get } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Data/Models/SeedPayload.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SeedPayload.swift 3 | // SwiftyPress 4 | // 5 | // Created by Basem Emara on 2018-06-12. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | public struct SeedPayload: Codable, Equatable { 10 | public let posts: [Post] 11 | public let authors: [Author] 12 | public let media: [Media] 13 | public let terms: [Term] 14 | 15 | init( 16 | posts: [Post] = [], 17 | authors: [Author] = [], 18 | media: [Media] = [], 19 | terms: [Term] = [] 20 | ) { 21 | self.posts = posts 22 | self.authors = authors 23 | self.media = media 24 | self.terms = terms 25 | } 26 | } 27 | 28 | public extension SeedPayload { 29 | 30 | /// A Boolean value indicating whether the instance is empty. 31 | var isEmpty: Bool { 32 | posts.isEmpty 33 | && authors.isEmpty 34 | && media.isEmpty 35 | && terms.isEmpty 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Repositories/Taxonomy/TaxonomyAPI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TaxonomyAPI.swift 3 | // SwiftyPress 4 | // 5 | // Created by Basem Emara on 2018-06-04. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | // MARK: - Cache 10 | 11 | public protocol TaxonomyCache { 12 | func fetch(id: Int, completion: @escaping (Result) -> Void) 13 | func fetch(slug: String, completion: @escaping (Result) -> Void) 14 | 15 | func fetch(completion: @escaping (Result<[Term], SwiftyPressError>) -> Void) 16 | func fetch(ids: Set, completion: @escaping (Result<[Term], SwiftyPressError>) -> Void) 17 | func fetch(by taxonomy: Taxonomy, completion: @escaping (Result<[Term], SwiftyPressError>) -> Void) 18 | func fetch(by taxonomies: [Taxonomy], completion: @escaping (Result<[Term], SwiftyPressError>) -> Void) 19 | 20 | func getID(bySlug slug: String) -> Int? 21 | } 22 | 23 | // MARK: - Namespace 24 | 25 | public enum TaxonomyAPI {} 26 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Views/UIKit/Extensions/UIViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIViewController.swift 3 | // SwiftyPress iOS 4 | // 5 | // Created by Basem Emara on 2018-10-08. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | #if os(iOS) 10 | import UIKit 11 | import ZamzamCore 12 | import ZamzamUI 13 | 14 | public extension UIViewController { 15 | 16 | /** 17 | Open Safari view controller overlay. 18 | 19 | - parameter url: URL to display in the browser. 20 | - parameter theme: The style of the Safari view controller. 21 | */ 22 | func present( 23 | safari url: String, 24 | theme: Theme, 25 | animated: Bool = true, 26 | completion: (() -> Void)? = nil 27 | ) { 28 | present( 29 | safari: url, 30 | barTintColor: theme.backgroundColor, 31 | preferredControlTintColor: theme.tint, 32 | animated: animated, 33 | completion: completion 34 | ) 35 | } 36 | } 37 | #endif 38 | -------------------------------------------------------------------------------- /Tests/SwiftyPressModelTests/Models/MediaTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MediaModelTests.swift 3 | // SwiftyPress ModelTests 4 | // 5 | // Created by Basem Emara on 2019-05-17. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | #if !os(watchOS) 10 | import XCTest 11 | import SwiftyPress 12 | import ZamzamCore 13 | 14 | final class MediaModelTests: XCTestCase { 15 | 16 | func testDecoding() throws { 17 | let model = try JSONDecoder.default.decode(Media.self, fromJSON: #fileID) 18 | 19 | XCTAssertEqual(model.id, 41346) 20 | XCTAssertEqual(model.link, "https://staging1.basememara.com/wp-content/uploads/2018/04/Clean-Architecture-Cycle-2.png") 21 | XCTAssertEqual(model.width, 500) 22 | XCTAssertEqual(model.height, 518) 23 | XCTAssertEqual(model.thumbnailLink, "https://staging1.basememara.com/wp-content/uploads/2018/04/Clean-Architecture-Cycle-2-500x518.png") 24 | XCTAssertEqual(model.thumbnailWidth, 500) 25 | XCTAssertEqual(model.thumbnailHeight, 518) 26 | } 27 | } 28 | #endif 29 | -------------------------------------------------------------------------------- /Tests/SwiftyPressTests/Mocks/DataJSONSeed.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Basem Emara on 2020-05-11. 6 | // 7 | 8 | #if !os(watchOS) 9 | import Foundation.NSJSONSerialization 10 | import ZamzamCore 11 | @testable import SwiftyPress 12 | 13 | struct DataJSONSeed: DataSeed { 14 | private static var seed: SeedPayload? 15 | 16 | func configure() {} 17 | 18 | func fetch(completion: (Result) -> Void) { 19 | if Self.seed == nil { 20 | do { 21 | Self.seed = try JSONDecoder.default.decode( 22 | SeedPayload.self, 23 | fromJSON: #fileID 24 | ) 25 | } catch { 26 | completion(.failure(.parseFailure(error))) 27 | return 28 | } 29 | } 30 | 31 | guard let seed = Self.seed else { 32 | completion(.failure(.nonExistent)) 33 | return 34 | } 35 | 36 | completion(.success(seed)) 37 | } 38 | } 39 | #endif 40 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Views/UIKit/Controls/SocialButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SocialButton.swift 3 | // SwiftyPress 4 | // 5 | // Created by Basem Emara on 2020-04-26. 6 | // Copyright © 2020 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | #if os(iOS) 10 | import UIKit 11 | 12 | /// A button that renders the social network icon. 13 | public class SocialButton: UIButton { 14 | 15 | public convenience init(social: Social, target: Any?, action: Selector, size: CGFloat = 32) { 16 | self.init(type: .custom) 17 | 18 | self.setImage(UIImage(named: social.rawValue), for: .normal) 19 | self.contentMode = .scaleAspectFit 20 | 21 | self.translatesAutoresizingMaskIntoConstraints = false 22 | self.heightAnchor.constraint(equalToConstant: size).isActive = true 23 | self.heightAnchor.constraint(equalTo: widthAnchor, multiplier: 1).isActive = true 24 | 25 | guard let index = social.index() else { return } 26 | self.addTarget(target, action: action, for: .touchUpInside) 27 | self.tag = index 28 | } 29 | } 30 | #endif 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Zamzam Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /Tests/SwiftyPressModelTests/Resources/Post/PostTests2.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 41294, 3 | "title": "So Swift, So Clean Architecture for iOS", 4 | "slug": "swift-clean-architecture", 5 | "type": "post", 6 | "excerpt": "The topic of iOS app architecture has evolved a long way from MVC. Unfortunately, the conversation becomes a frameworks and patterns war. The reality is: Rx is a framework; MVVM is a presentation pattern; and so on. Frameworks and patterns always come and go, but architectures are timeless. In this post, we will examine the Clean Architecture for building scalable apps in iOS.", 7 | "content": "This is the content.", 8 | "link": "https://staging1.basememara.com/swift-clean-architecture/", 9 | "comment_count": 10, 10 | "author": 2, 11 | "featured_media": null, 12 | "terms": [ 13 | 80, 14 | 79, 15 | 53, 16 | 14, 17 | 62, 18 | 50, 19 | 55 20 | ], 21 | "meta": { 22 | "_edit_lock": "1538307929:2", 23 | "_series_part": "1" 24 | }, 25 | "created": "2018-04-22T22:03:20", 26 | "modified": "2018-09-30T11:47:51" 27 | } 28 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Errors/SwiftyPressError+Network.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DataError+Network.swift 3 | // SwiftyPress 4 | // 5 | // Created by Basem Emara on 2020-03-26. 6 | // Copyright © 2020 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | import Foundation.NSURLError 10 | import ZamzamCore 11 | 12 | public extension SwiftyPressError { 13 | 14 | init(from error: NetworkAPI.Error) { 15 | // Handle no internet 16 | if let internalError = error.internalError as? URLError, 17 | internalError.code == .notConnectedToInternet { 18 | self = .noInternet 19 | return 20 | } 21 | 22 | // Handle timeout 23 | if let internalError = error.internalError as? URLError, 24 | internalError.code == .timedOut { 25 | self = .timeout 26 | return 27 | } 28 | 29 | // Handle by status code 30 | switch error.statusCode { 31 | case 400: 32 | self = .requestFailure(error) 33 | case 401, 403: 34 | self = .unauthorized 35 | default: 36 | self = .serverFailure(error) 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Preferences/Shared/Preferences.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Preferences.swift 3 | // SwiftyPress 4 | // 5 | // Created by Basem Emara on 2018-10-06. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | import ZamzamCore 10 | 11 | public extension Preferences { 12 | 13 | /// Returns the current user's ID, or nil if an anonymous user. 14 | var userID: Int? { self.get(.userID) } 15 | 16 | /// Returns the current favorite posts. 17 | var favorites: [Int] { self.get(.favorites) ?? [] } 18 | } 19 | 20 | // MARK: - Mutating 21 | 22 | extension Preferences { 23 | 24 | func set(userID value: Int) { 25 | set(value, forKey: .userID) 26 | } 27 | 28 | func set(favorites value: [Int]) { 29 | set(value, forKey: .favorites) 30 | } 31 | 32 | /// Removes all the user defaults items. 33 | func removeAll() { 34 | remove(.userID) 35 | remove(.favorites) 36 | } 37 | } 38 | 39 | // MARK: - Helpers 40 | 41 | private extension PreferencesAPI.Keys { 42 | static let userID = PreferencesAPI.Key("userID") 43 | static let favorites = PreferencesAPI.Key<[Int]?>("favorites") 44 | } 45 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Styles/Theme.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Theme.swift 3 | // SwiftyPress 4 | // 5 | // Created by Basem Emara on 2018-10-06. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | #if canImport(UIKit) 10 | import UIKit 11 | #elseif canImport(AppKit) 12 | import AppKit 13 | public typealias UIColor = NSColor 14 | #endif 15 | 16 | public protocol Theme { 17 | var tint: UIColor { get } 18 | var secondaryTint: UIColor { get } 19 | 20 | var backgroundColor: UIColor { get } 21 | var secondaryBackgroundColor: UIColor { get } 22 | var tertiaryBackgroundColor: UIColor { get } 23 | var quaternaryBackgroundColor: UIColor { get } 24 | 25 | var separatorColor: UIColor { get } 26 | var opaqueColor: UIColor { get } 27 | 28 | var labelColor: UIColor { get } 29 | var secondaryLabelColor: UIColor { get } 30 | var tertiaryLabelColor: UIColor { get } 31 | var quaternaryLabelColor: UIColor { get } 32 | var placeholderLabelColor: UIColor { get } 33 | 34 | var buttonCornerRadius: CGFloat { get } 35 | 36 | var positiveColor: UIColor { get } 37 | var negativeColor: UIColor { get } 38 | 39 | var isDarkStyle: Bool { get } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Repositories/Post/Models/ExtendedPost.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ExtendedPost.swift 3 | // SwiftyPress 4 | // 5 | // Created by Basem Emara on 2019-05-11. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | import ZamzamCore 10 | 11 | // Type used for decoding the server payload 12 | public struct ExtendedPost: Equatable { 13 | public let post: Post 14 | public let author: Author? 15 | public let media: Media? 16 | public let terms: [Term] 17 | } 18 | 19 | // MARK: - Conversions 20 | 21 | extension ExtendedPost: Codable { 22 | 23 | private enum CodingKeys: String, CodingKey { 24 | case post 25 | case author 26 | case media 27 | case terms 28 | } 29 | 30 | public init(from decoder: Decoder) throws { 31 | let container = try decoder.container(keyedBy: CodingKeys.self) 32 | 33 | self.post = try container.decode(Post.self, forKey: .post) 34 | self.author = try container.decode(Author.self, forKey: .author) 35 | self.media = try container.decode(Media.self, forKey: .media) 36 | self.terms = try container.decode(FailableCodableArray.self, forKey: .terms).elements 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Data/DataAPI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DataAPI.swift 3 | // SwiftyPress 4 | // 5 | // Created by Basem Emara on 2018-06-12. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | import Foundation.NSDate 10 | 11 | // MARK: - Services 12 | 13 | public protocol DataService { 14 | func fetchModified(after date: Date?, with request: DataAPI.ModifiedRequest, completion: @escaping (Result) -> Void) 15 | } 16 | 17 | // MARK: - Cache 18 | 19 | public protocol DataCache { 20 | var lastFetchedAt: Date? { get } 21 | 22 | func configure() 23 | func createOrUpdate(with request: DataAPI.CacheRequest, completion: @escaping (Result) -> Void) 24 | func delete(for userID: Int) 25 | } 26 | 27 | // MARK: - Seed 28 | 29 | public protocol DataSeed { 30 | func configure() 31 | func fetch(completion: (Result) -> Void) 32 | } 33 | 34 | // MARK: - Namespace 35 | 36 | public enum DataAPI { 37 | 38 | public struct ModifiedRequest { 39 | let taxonomies: [String] 40 | let postMetaKeys: [String] 41 | let limit: Int? 42 | } 43 | 44 | public struct CacheRequest { 45 | let payload: SeedPayload 46 | let lastFetchedAt: Date 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Tests/SwiftyPressTests/Repositories/FavoriteRepositoryTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FavoriteRepositoryTests.swift 3 | // SwiftyPress 4 | // 5 | // Created by Basem Emara on 2020-05-25. 6 | // 7 | 8 | #if !os(watchOS) 9 | import XCTest 10 | import SwiftyPress 11 | 12 | final class FavoriteRepositoryTests: TestCase { 13 | private lazy var favoriteRepository = core.favoriteRepository() 14 | } 15 | 16 | extension FavoriteRepositoryTests { 17 | 18 | func testFavorites() { 19 | // Given 20 | var promise: XCTestExpectation? = expectation(description: #function) 21 | let ids = [5568, 26200] 22 | 23 | // When 24 | favoriteRepository.add(id: ids[0]) 25 | favoriteRepository.add(id: ids[1]) 26 | 27 | favoriteRepository.fetch { 28 | // Handle double calls used for remote fetching 29 | guard $0.value?.isEmpty == false else { return } 30 | defer { promise?.fulfill(); promise = nil } 31 | 32 | // Then 33 | XCTAssertNil($0.error, $0.error.debugDescription) 34 | XCTAssertNotNil($0.value, "response should not have been nil") 35 | XCTAssert($0.value?.map { $0.id }.sorted() == ids) 36 | } 37 | 38 | waitForExpectations(timeout: 10, handler: nil) 39 | } 40 | } 41 | #endif 42 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | analyzer_rules: 2 | - unused_import 3 | - unused_private_declaration 4 | 5 | excluded: 6 | - Assets 7 | - Tests 8 | 9 | opt_in_rules: 10 | - array_init 11 | - contains_over_first_not_nil 12 | - empty_count 13 | - empty_string 14 | - explicit_init 15 | - fatal_error_message 16 | - first_where 17 | - force_unwrapping 18 | - function_default_parameter_at_end 19 | - implicitly_unwrapped_optional 20 | - last_where 21 | - lower_acl_than_parent 22 | - multiline_function_chains 23 | - multiline_parameters 24 | - multiline_parameters_brackets 25 | - multiple_closures_with_trailing_closure 26 | - private_action 27 | - private_outlet 28 | - static_operator 29 | - switch_case_on_newline 30 | - toggle_bool 31 | - unneeded_break_in_switch 32 | - unused_enumerated 33 | - unused_optional_binding 34 | - unused_setter_value 35 | - vertical_parameter_alignment 36 | - vertical_parameter_alignment_on_call 37 | - void_return 38 | - weak_delegate 39 | - xctfail_message 40 | - xct_specific_matcher 41 | - yoda_condition 42 | 43 | disabled_rules: 44 | - function_body_length 45 | - function_default_parameter_at_end 46 | - function_parameter_count 47 | - identifier_name 48 | - line_length 49 | - todo 50 | - large_tuple 51 | - lower_acl_than_parent 52 | - trailing_whitespace 53 | - vertical_parameter_alignment_on_call 54 | - multiline_function_chains 55 | - sorted_imports 56 | 57 | file_length: 750 58 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Data/Services/DataFileSeed.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DataFileSeed.swift 3 | // SwiftyPress 4 | // 5 | // Created by Basem Emara on 2018-06-12. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | import Foundation.NSBundle 10 | import ZamzamCore 11 | 12 | public struct DataFileSeed: DataSeed { 13 | private static var data: SeedPayload? 14 | 15 | private let name: String 16 | private let bundle: Bundle 17 | private let jsonDecoder: JSONDecoder 18 | 19 | public init(forResource name: String, inBundle bundle: Bundle, jsonDecoder: JSONDecoder) { 20 | self.name = name 21 | self.bundle = bundle 22 | self.jsonDecoder = jsonDecoder 23 | } 24 | } 25 | 26 | public extension DataFileSeed { 27 | 28 | func configure() { 29 | guard Self.data == nil else { return } 30 | 31 | Self.data = try? jsonDecoder.decode( 32 | SeedPayload.self, 33 | forResource: name, 34 | inBundle: bundle 35 | ) 36 | } 37 | } 38 | 39 | public extension DataFileSeed { 40 | 41 | func fetch(completion: (Result) -> Void) { 42 | completion(.success(Self.data ?? SeedPayload())) 43 | } 44 | } 45 | 46 | public extension DataFileSeed { 47 | 48 | func set(data: SeedPayload) { 49 | Self.data = data 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Extensions/Dictionary.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Dictionary.swift 3 | // SwiftyPress 4 | // 5 | // Created by Basem Emara on 2018-06-17. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | private extension Dictionary { 10 | 11 | /// Keys to scrub 12 | static var scrubKeys: [String] { 13 | [ 14 | "Authorization", 15 | "Set-Cookie", 16 | "user_email", 17 | "password", 18 | "first_name", 19 | "last_name", 20 | "email", 21 | "phone_number", 22 | "profile_picture_url", 23 | "token" 24 | ] 25 | } 26 | 27 | static func scrub(value: [AnyHashable: Any]) -> [String: String] { 28 | [String: String](uniqueKeysWithValues: value.map { 29 | let key = "\($0)" 30 | let value = !key.within(scrubKeys) || ($1 as? String)?.isEmpty == true ? "\($1)" : "*****" 31 | return (key, value) 32 | }) 33 | } 34 | } 35 | 36 | extension Dictionary where Key == AnyHashable, Value == Any { 37 | 38 | /// Remove sensitive info from headers 39 | var scrubbed: [String: String] { Self.scrub(value: self) } 40 | } 41 | 42 | extension Dictionary where Key == String, Value == String { 43 | 44 | /// Remove sensitive info from headers 45 | var scrubbed: [String: String] { Self.scrub(value: self) } 46 | } 47 | -------------------------------------------------------------------------------- /Tests/SwiftyPressTests/TestUtilities.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestUtilities.swift 3 | // SwiftyPress 4 | // 5 | // Created by Basem Emara on 2019-05-11. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | #if !os(watchOS) 10 | import Foundation 11 | 12 | extension UserDefaults { 13 | static let test = UserDefaults(suiteName: "SwiftyPressTests")! 14 | } 15 | 16 | extension JSONDecoder { 17 | 18 | /// Decodes the given type from the given JSON representation of the current file. 19 | func decode(_ type: T.Type, fromJSON file: String, suffix: String? = nil) throws -> T where T : Decodable { 20 | let fileName = URL(fileURLWithPath: file) 21 | .appendingToFileName(suffix ?? "") 22 | .deletingPathExtension() 23 | .lastPathComponent 24 | 25 | guard let url = Bundle.module.url(forResource: fileName, withExtension: "json"), 26 | let data = try? Data(contentsOf: url) 27 | else { 28 | throw NSError(domain: "SwiftyPressTests.JSONDecoder", code: NSFileReadUnknownError) 29 | } 30 | 31 | return try decode(type, from: data) 32 | } 33 | } 34 | 35 | extension Result { 36 | 37 | var value: Success? { 38 | switch self { 39 | case .success(let value): 40 | return value 41 | case .failure: 42 | return nil 43 | } 44 | } 45 | } 46 | #endif 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SwiftyPress 2 | 3 | [![Platform](https://img.shields.io/badge/platform-macos%20%7C%20ios%20%7C%20watchos%20%7C%20ipados%20%7C%20tvos-lightgrey)](https://github.com/ZamzamInc/ZamzamKit) 4 | [![Swift](https://img.shields.io/badge/Swift-5-orange.svg)](https://swift.org) 5 | [![Xcode](https://img.shields.io/badge/Xcode-11-blue.svg)](https://developer.apple.com/xcode) 6 | [![SPM](https://img.shields.io/badge/SPM-Compatible-blue)](https://swift.org/package-manager) 7 | [![MIT](https://img.shields.io/badge/License-MIT-red.svg)](https://opensource.org/licenses/MIT) 8 | 9 | SwiftyPress is a Swift framework for integrating WordPress 4.7+ to native iOS apps using REST API's. 10 | 11 | It promotes [Clean Architecture](http://basememara.com/swift-clean-architecture/) and a brief overview can be [read here](http://basememara.com/full-stack-ios-and-wordpress-in-swift/). 12 | 13 | ## Installation 14 | 15 | ### Swift Package Manager 16 | 17 | `.package(url: "git@github.com:ZamzamInc/SwiftyPress.git", .upToNextMajor(from: "3.0.3"))` 18 | 19 | *A limitation with Swift Package Manager requires resources to be embedded manually. Drag `/Resources/SwiftyPress.bundle` to your Xcode project's `Build Phases > Copy Bundle` section.* 20 | 21 | ## Author 22 | 23 | * Zamzam, https://zamzam.io 24 | * Basem Emara, https://basememara.com 25 | 26 | ## License 27 | 28 | SwiftyPress is available under the MIT license. See the LICENSE file for more info. 29 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Views/UIKit/Extensions/UIImage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIImage.swift 3 | // SwiftyPress 4 | // 5 | // Created by Basem Emara on 2020-04-25. 6 | // Copyright © 2020 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | import UIKit.UIImage 10 | 11 | public extension UIImage { 12 | 13 | /// Returns the image object associated with the specified filename. 14 | /// 15 | /// - Parameter name: Enum case for image name 16 | convenience init?(named name: ImageName, inBundle bundle: Bundle? = nil) { 17 | self.init(named: name.rawValue, inBundle: bundle) 18 | } 19 | 20 | enum ImageName: String { 21 | case placeholder 22 | case emptyPlaceholder = "empty-set" 23 | case favoriteEmpty = "favorite-empty" 24 | case favoriteFilled = "favorite-filled" 25 | case more = "more-icon" 26 | case comments = "comments" 27 | case theme = "theme" 28 | case tabHome = "tab-home" 29 | case tabBlog = "tab-megaphone" 30 | case tabFavorite = "tab-favorite" 31 | case tabSearch = "tab-search" 32 | case tabMore = "tab-more" 33 | case signup = "signup" 34 | case feedback = "feedback" 35 | case idea = "idea" 36 | case rating = "rating" 37 | case megaphone = "megaphone" 38 | case settings = "settings" 39 | case design = "design" 40 | case notifications = "notifications" 41 | case phone = "phone" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Tests/SwiftyPressModelTests/Models/AuthorTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthorModelTests.swift 3 | // SwiftyPress ModelTests 4 | // 5 | // Created by Basem Emara on 2019-05-17. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | #if !os(watchOS) 10 | import XCTest 11 | import SwiftyPress 12 | import ZamzamCore 13 | 14 | final class AuthorModelTests: XCTestCase { 15 | 16 | func testDecoding() throws { 17 | let model = try JSONDecoder.default.decode(Author.self, fromJSON: #fileID) 18 | 19 | XCTAssertEqual(model.id, 2) 20 | XCTAssertEqual(model.name, "Basem Emara") 21 | XCTAssertEqual(model.link, "https://staging1.basememara.com") 22 | XCTAssertEqual(model.avatar, "https://secure.gravatar.com/avatar/8def0d36f56d3e6720a44e41bf6f9a71?s=96&d=mm&r=g") 23 | XCTAssertEqual(model.content, "Basem is a mobile and software IT professional with over 12 years of experience as an architect, developer, and consultant for dozens of projects that span over various industries for Fortune 500 enterprises, government agencies, and startups. In 2014, Basem brought his vast knowledge and experiences to Swift and helped pioneer the language to build scalable enterprise iOS & watchOS apps, later providing mentorship courses at https://iosmentor.io.") 24 | XCTAssertEqual(model.createdAt, DateFormatter.iso8601.date(from: "2015-02-02T03:39:52")) 25 | XCTAssertEqual(model.modifiedAt, DateFormatter.iso8601.date(from: "2018-10-06T14:43:53")) 26 | } 27 | } 28 | #endif 29 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Repositories/Media/Models/MediaRealmObject.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MediaRealmObject.swift 3 | // SwiftyPress 4 | // 5 | // Created by Basem Emara on 2018-10-17. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | import RealmSwift 10 | 11 | @objcMembers 12 | class MediaRealmObject: Object, MediaType { 13 | dynamic var id: Int = 0 14 | dynamic var link: String = "" 15 | dynamic var width: Int = 0 16 | dynamic var height: Int = 0 17 | dynamic var thumbnailLink: String = "" 18 | dynamic var thumbnailWidth: Int = 0 19 | dynamic var thumbnailHeight: Int = 0 20 | 21 | override static func primaryKey() -> String? { 22 | "id" 23 | } 24 | } 25 | 26 | extension MediaRealmObject { 27 | 28 | /// For converting to one type to another. 29 | /// 30 | /// - Parameter object: An instance of media type. 31 | convenience init(from object: MediaType) { 32 | self.init() 33 | self.id = object.id 34 | self.link = object.link 35 | self.width = object.width 36 | self.height = object.height 37 | self.thumbnailLink = object.thumbnailLink 38 | self.thumbnailWidth = object.thumbnailWidth 39 | self.thumbnailHeight = object.thumbnailHeight 40 | } 41 | 42 | /// For converting to one type to another. 43 | /// 44 | /// - Parameter object: An instance of media type. 45 | convenience init?(from object: MediaType?) { 46 | guard let object = object else { return nil } 47 | self.init(from: object) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Repositories/Media/MediaRepository.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MediaRepository.swift 3 | // SwiftPress 4 | // 5 | // Created by Basem Emara on 2018-05-29. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | public struct MediaRepository { 10 | private let service: MediaService 11 | private let cache: MediaCache 12 | 13 | public init(service: MediaService, cache: MediaCache) { 14 | self.service = service 15 | self.cache = cache 16 | } 17 | } 18 | 19 | public extension MediaRepository { 20 | 21 | func fetch(id: Int, completion: @escaping (Result) -> Void) { 22 | cache.fetch(id: id) { 23 | // Retrieve missing cache data from cloud if applicable 24 | if case .nonExistent? = $0.error { 25 | self.service.fetch(id: id) { 26 | guard case let .success(item) = $0 else { 27 | completion($0) 28 | return 29 | } 30 | 31 | self.cache.createOrUpdate(item, completion: completion) 32 | } 33 | 34 | return 35 | } 36 | 37 | // Immediately return local response 38 | completion($0) 39 | } 40 | } 41 | } 42 | 43 | public extension MediaRepository { 44 | 45 | func fetch(ids: Set, completion: @escaping (Result<[Media], SwiftyPressError>) -> Void) { 46 | cache.fetch(ids: ids, completion: completion) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Tests/SwiftyPressTests/Repositories/MediaRepositoryTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MediaRepositoryTests.swift 3 | // SwiftyPress 4 | // 5 | // Created by Basem Emara on 2018-06-03. 6 | // 7 | 8 | #if !os(watchOS) 9 | import XCTest 10 | import SwiftyPress 11 | 12 | final class MediaRepositoryTests: TestCase { 13 | private lazy var mediaRepository = core.mediaRepository() 14 | } 15 | 16 | extension MediaRepositoryTests { 17 | 18 | func testFetchByID() { 19 | // Given 20 | let promise = expectation(description: #function) 21 | let id = 41397 22 | 23 | // When 24 | mediaRepository.fetch(id: id) { 25 | defer { promise.fulfill() } 26 | 27 | // Then 28 | XCTAssertNil($0.error, $0.error.debugDescription) 29 | XCTAssertNotNil($0.value, "response should not have been nil") 30 | XCTAssertTrue($0.value?.id == id) 31 | } 32 | 33 | waitForExpectations(timeout: 10, handler: nil) 34 | } 35 | 36 | func testFetchByIDError() { 37 | // Given 38 | let promise = expectation(description: #function) 39 | let id = 999999 40 | 41 | // When 42 | mediaRepository.fetch(id: id) { 43 | defer { promise.fulfill() } 44 | 45 | // Then 46 | guard case .nonExistent? = $0.error else { 47 | return XCTFail("response should have failed") 48 | } 49 | } 50 | 51 | waitForExpectations(timeout: 10, handler: nil) 52 | } 53 | } 54 | #endif 55 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "Nuke", 6 | "repositoryURL": "https://github.com/kean/Nuke.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "998588c80611e4fe9b71dd4323fa938ee62f4ff7", 10 | "version": "9.1.1" 11 | } 12 | }, 13 | { 14 | "package": "Realm", 15 | "repositoryURL": "https://github.com/realm/realm-cocoa.git", 16 | "state": { 17 | "branch": null, 18 | "revision": "b37e031434ed1e91c9254ef124fbac92b1a4c873", 19 | "version": "5.3.4" 20 | } 21 | }, 22 | { 23 | "package": "RealmCore", 24 | "repositoryURL": "https://github.com/realm/realm-core", 25 | "state": { 26 | "branch": null, 27 | "revision": "b0c2d353112e284011ff0c898907392a9f4f15b6", 28 | "version": "6.0.18" 29 | } 30 | }, 31 | { 32 | "package": "Stencil", 33 | "repositoryURL": "https://github.com/ZamzamInc/Stencil.git", 34 | "state": { 35 | "branch": null, 36 | "revision": "2ad923ba172ace3c38a3995eedbec03e322c828b", 37 | "version": "0.13.2" 38 | } 39 | }, 40 | { 41 | "package": "ZamzamKit", 42 | "repositoryURL": "https://github.com/ZamzamInc/ZamzamKit.git", 43 | "state": { 44 | "branch": null, 45 | "revision": "c977ba41eabf4aa1b1d2faeb08b653e953d5f48a", 46 | "version": "6.1.3" 47 | } 48 | } 49 | ] 50 | }, 51 | "version": 1 52 | } 53 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Repositories/Author/Models/AuthorRealmObject.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthorRealmObject.swift 3 | // SwiftyPress 4 | // 5 | // Created by Basem Emara on 2018-10-17. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | import Foundation.NSDate 10 | import RealmSwift 11 | 12 | @objcMembers 13 | class AuthorRealmObject: Object, AuthorType { 14 | dynamic var id: Int = 0 15 | dynamic var name: String = "" 16 | dynamic var link: String = "" 17 | dynamic var avatar: String = "" 18 | dynamic var content: String = "" 19 | dynamic var createdAt: Date = .distantPast 20 | dynamic var modifiedAt: Date = .distantPast 21 | 22 | override static func primaryKey() -> String? { 23 | "id" 24 | } 25 | } 26 | 27 | // MARK: - Conversions 28 | 29 | extension AuthorRealmObject { 30 | 31 | /// For converting to one type to another. 32 | /// 33 | /// - Parameter object: An instance of author type. 34 | convenience init(from object: AuthorType) { 35 | self.init() 36 | self.id = object.id 37 | self.name = object.name 38 | self.link = object.link 39 | self.avatar = object.avatar 40 | self.content = object.content 41 | self.createdAt = object.createdAt 42 | self.modifiedAt = object.modifiedAt 43 | } 44 | 45 | /// For converting to one type to another. 46 | /// 47 | /// - Parameter object: An instance of author type. 48 | convenience init?(from object: AuthorType?) { 49 | guard let object = object else { return nil } 50 | self.init(from: object) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Tests/SwiftyPressTests/TestCore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestConfigurator.swift 3 | // SwiftyPress 4 | // 5 | // Created by Basem Emara on 2019-05-11. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | #if !os(watchOS) 10 | import Foundation.NSJSONSerialization 11 | import Foundation.NSURL 12 | import ZamzamCore 13 | @testable import SwiftyPress 14 | 15 | struct TestCore: SwiftyPressCore { 16 | 17 | func constantsService() -> ConstantsService { 18 | ConstantsStaticService( 19 | isDebug: true, 20 | itunesName: "", 21 | itunesID: "0", 22 | baseURL: URL(string: "https://basememara.com")!, 23 | baseREST: "wp-json/swiftypress/v5", 24 | wpREST: "wp-json/wp/v2", 25 | email: "test@example.com", 26 | privacyURL: "", 27 | disclaimerURL: nil, 28 | styleSheet: "", 29 | googleAnalyticsID: nil, 30 | featuredCategoryID: 64, 31 | defaultFetchModifiedLimit: 25, 32 | taxonomies: ["category", "post_tag", "series"], 33 | postMetaKeys: ["_edit_lock", "_series_part"], 34 | minLogLevel: .verbose 35 | ) 36 | } 37 | 38 | func preferencesService() -> PreferencesService { 39 | PreferencesDefaultsService(defaults: .test) 40 | } 41 | 42 | func logServices() -> [LogService] { 43 | [LogConsoleService(minLevel: .verbose)] 44 | } 45 | 46 | func dataSeed() -> DataSeed { 47 | DataJSONSeed() 48 | } 49 | 50 | func theme() -> Theme { 51 | fatalError("Not implemented") 52 | } 53 | } 54 | #endif 55 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Views/UIKit/Controls/DataViews/PostsDataView/PostsDataViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PostsDataViewModel.swift 3 | // Basem Emara 4 | // 5 | // Created by Basem Emara on 2018-06-21. 6 | // Copyright © 2018 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | import Foundation.NSDateFormatter 10 | 11 | public struct PostsDataViewModel: Identifiable, Equatable { 12 | public let id: Int 13 | public let title: String 14 | public let summary: String 15 | public let content: String 16 | public let link: String 17 | public let date: String 18 | public let imageURL: String? 19 | public let favorite: Bool 20 | } 21 | 22 | public extension PostsDataViewModel { 23 | 24 | func toggled(favorite: Bool) -> Self { 25 | PostsDataViewModel( 26 | id: id, 27 | title: title, 28 | summary: summary, 29 | content: content, 30 | link: link, 31 | date: date, 32 | imageURL: imageURL, 33 | favorite: favorite 34 | ) 35 | } 36 | } 37 | 38 | public extension PostsDataViewModel { 39 | 40 | init(from object: PostType, mediaURL: String?, favorite: Bool, dateFormatter: DateFormatter) { 41 | self.id = object.id 42 | self.title = object.title 43 | self.summary = !object.excerpt.isEmpty ? object.excerpt 44 | : object.content.htmlStripped.htmlDecoded().prefix(150).string 45 | self.content = object.content 46 | self.link = object.link 47 | self.date = dateFormatter.string(from: object.createdAt) 48 | self.imageURL = mediaURL 49 | self.favorite = favorite 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Repositories/Author/Services/AuthorFileCache.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthorFileCache.swift 3 | // SwiftyPress 4 | // 5 | // Created by Basem Emara on 2018-06-04. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | import ZamzamCore 10 | 11 | public struct AuthorFileCache: AuthorCache { 12 | private let seedService: DataSeed 13 | 14 | init(seedService: DataSeed) { 15 | self.seedService = seedService 16 | } 17 | } 18 | 19 | public extension AuthorFileCache { 20 | 21 | func fetch(with request: AuthorAPI.FetchRequest, completion: @escaping (Result) -> Void) { 22 | seedService.fetch { 23 | guard case let .success(item) = $0 else { 24 | completion(.failure($0.error ?? .unknownReason(nil))) 25 | return 26 | } 27 | 28 | // Find match 29 | guard let model = item.authors.first(where: { $0.id == request.id }) else { 30 | completion(.failure(.nonExistent)) 31 | return 32 | } 33 | 34 | completion(.success(model)) 35 | } 36 | } 37 | } 38 | 39 | public extension AuthorFileCache { 40 | 41 | func createOrUpdate(_ request: Author, completion: @escaping (Result) -> Void) { 42 | completion(.failure(.cacheFailure(nil))) 43 | } 44 | } 45 | 46 | public extension AuthorFileCache { 47 | 48 | func subscribe(with request: AuthorAPI.FetchRequest, in cancellable: inout Cancellable?, change block: @escaping (ChangeResult) -> Void) { 49 | block(.failure(.cacheFailure(nil))) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Repositories/Favorite/FavoriteRepository.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FavoriteRepository.swift 3 | // SwiftPress 4 | // 5 | // Created by Basem Emara on 2020-05-25. 6 | // Copyright © 2020 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | import ZamzamCore 10 | 11 | public struct FavoriteRepository { 12 | private let postRepository: PostRepository 13 | private let preferences: Preferences 14 | 15 | public init(postRepository: PostRepository, preferences: Preferences) { 16 | self.postRepository = postRepository 17 | self.preferences = preferences 18 | } 19 | } 20 | 21 | public extension FavoriteRepository { 22 | 23 | func fetch(completion: @escaping (Result<[Post], SwiftyPressError>) -> Void) { 24 | guard !preferences.favorites.isEmpty else { 25 | completion(.success([])) 26 | return 27 | } 28 | 29 | postRepository.fetch(ids: Set(preferences.favorites), completion: completion) 30 | } 31 | 32 | func fetchIDs(completion: @escaping (Result<[Int], SwiftyPressError>) -> Void) { 33 | completion(.success(preferences.favorites)) 34 | } 35 | 36 | func add(id: Int) { 37 | guard !contains(id: id) else { return } 38 | preferences.set(favorites: preferences.favorites + [id]) 39 | } 40 | 41 | func remove(id: Int) { 42 | let updated = preferences.favorites.filter { $0 != id } 43 | preferences.set(favorites: updated) 44 | } 45 | 46 | func toggle(id: Int) { 47 | contains(id: id) ? remove(id: id) : add(id: id) 48 | } 49 | 50 | func contains(id: Int) -> Bool { 51 | preferences.favorites.contains(id) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "SwiftyPress", 7 | defaultLocalization: "en", 8 | platforms: [ 9 | .macOS(.v10_14), 10 | .iOS(.v11), 11 | .tvOS(.v11), 12 | .watchOS(.v4) 13 | ], 14 | products: [ 15 | .library(name: "SwiftyPress", type: .dynamic, targets: ["SwiftyPress"]) 16 | ], 17 | dependencies: [ 18 | .package(name: "Realm", url: "https://github.com/realm/realm-cocoa.git", .exact("5.3.4")), 19 | .package(url: "https://github.com/ZamzamInc/ZamzamKit.git", .exact("7.0.0-beta.1")), 20 | .package(url: "https://github.com/ZamzamInc/Stencil.git", .exact("0.13.2")), 21 | .package(url: "https://github.com/kean/Nuke.git", .exact("9.1.1")) 22 | ], 23 | targets: [ 24 | .target( 25 | name: "SwiftyPress", 26 | dependencies: [ 27 | "Realm", 28 | .product(name: "RealmSwift", package: "Realm"), 29 | .product(name: "ZamzamCore", package: "ZamzamKit"), 30 | .product(name: "ZamzamNotification", package: "ZamzamKit"), 31 | .product(name: "ZamzamUI", package: "ZamzamKit"), 32 | "Stencil", 33 | "Nuke" 34 | ] 35 | ), 36 | .testTarget( 37 | name: "SwiftyPressTests", 38 | dependencies: ["SwiftyPress"], 39 | resources: [.process("Resources")] 40 | ), 41 | .testTarget( 42 | name: "SwiftyPressModelTests", 43 | dependencies: ["SwiftyPress"], 44 | resources: [.process("Resources")] 45 | ) 46 | ] 47 | ) 48 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Models/ChangeResult.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChangeResult.swift 3 | // SwiftPress 4 | // 5 | // Created by Basem Emara on 2020-05-10. 6 | // Copyright © 2020 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | /// A value that represents information about changes to a value or a failure, including an associated value in each case. 10 | @frozen public enum ChangeResult where Failure: Error { 11 | 12 | /// Indicates that the initial run of the result has completed. 13 | case initial(Value) 14 | 15 | /// Indicates that a modification of the value has occurred from the initial state previously returned. 16 | case update(Value) 17 | 18 | /// A failure, storing a `Failure` value. 19 | case failure(Failure) 20 | } 21 | 22 | // MARK: - Helpers 23 | 24 | public extension ChangeResult { 25 | 26 | /// Returns the associated error value if the result is a failure, `nil` otherwise. 27 | var error: Failure? { 28 | switch self { 29 | case .failure(let error): 30 | return error 31 | default: 32 | return nil 33 | } 34 | } 35 | } 36 | 37 | public extension Result { 38 | 39 | /// Transform `Result` to single `ChangeResult` by calling the initial or failure cases for convenience. 40 | /// 41 | /// // Executes `.initial` or `.failure` 42 | /// fetch(completion: { $0(completion) }) 43 | /// 44 | func callAsFunction(_ change: @escaping (ChangeResult) -> Void) { 45 | switch self { 46 | case .success(let item): 47 | change(.initial(item)) 48 | case .failure(let error): 49 | change(.failure(error)) 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Preferences/Constants/Constants.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Constants.swift 3 | // SwiftPress 4 | // 5 | // Created by Basem Emara on 2018-05-21. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | import Foundation.NSURL 10 | import ZamzamCore 11 | 12 | public struct Constants: AppContext { 13 | private let service: ConstantsService 14 | 15 | public init(service: ConstantsService) { 16 | self.service = service 17 | } 18 | } 19 | 20 | public extension Constants { 21 | var isDebug: Bool { service.isDebug } 22 | } 23 | 24 | public extension Constants { 25 | var itunesName: String { service.itunesName } 26 | var itunesID: String { service.itunesID } 27 | } 28 | 29 | public extension Constants { 30 | var baseURL: URL { service.baseURL } 31 | var baseREST: String { service.baseREST } 32 | var wpREST: String { service.wpREST } 33 | } 34 | 35 | public extension Constants { 36 | var email: String { service.email } 37 | var privacyURL: String { service.privacyURL } 38 | var disclaimerURL: String? { service.disclaimerURL } 39 | var styleSheet: String { service.styleSheet } 40 | var googleAnalyticsID: String? { service.googleAnalyticsID } 41 | } 42 | 43 | public extension Constants { 44 | var featuredCategoryID: Int { service.featuredCategoryID } 45 | var defaultFetchModifiedLimit: Int { service.defaultFetchModifiedLimit } 46 | var taxonomies: [String] { service.taxonomies } 47 | var postMetaKeys: [String] { service.postMetaKeys } 48 | } 49 | 50 | public extension Constants { 51 | var minLogLevel: LogAPI.Level { service.minLogLevel } 52 | } 53 | 54 | public extension Constants { 55 | var itunesURL: String { "https://itunes.apple.com/app/id\(itunesID)" } 56 | } 57 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Repositories/Taxonomy/Models/TermRealmObject.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TermRealmObject.swift 3 | // SwiftyPress 4 | // 5 | // Created by Basem Emara on 2018-10-17. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | import RealmSwift 10 | 11 | @objcMembers 12 | class TermRealmObject: Object, TermType { 13 | dynamic var id: Int = 0 14 | dynamic var parentID: Int = 0 15 | dynamic var slug: String = "" 16 | dynamic var name: String = "" 17 | dynamic var content: String? 18 | dynamic var taxonomyRaw: String = Taxonomy.category.rawValue 19 | dynamic var count: Int = 0 20 | 21 | override static func primaryKey() -> String? { 22 | "id" 23 | } 24 | 25 | override static func indexedProperties() -> [String] { 26 | ["slug", "taxonomy"] 27 | } 28 | } 29 | 30 | // MARK: - Workarounds 31 | 32 | extension TermRealmObject { 33 | 34 | var taxonomy: Taxonomy { 35 | get { Taxonomy(rawValue: taxonomyRaw) ?? .category } 36 | set { taxonomyRaw = newValue.rawValue } 37 | } 38 | } 39 | 40 | // MARK: - Conversions 41 | 42 | extension TermRealmObject { 43 | 44 | /// For converting to one type to another 45 | /// 46 | /// - Parameter object: An instance of term type. 47 | convenience init(from object: TermType) { 48 | self.init() 49 | self.id = object.id 50 | self.parentID = object.parentID 51 | self.slug = object.slug 52 | self.name = object.name 53 | self.content = object.content 54 | self.taxonomy = object.taxonomy 55 | self.count = object.count 56 | } 57 | 58 | /// For converting to one type to another 59 | /// 60 | /// - Parameter object: An instance of term type. 61 | convenience init?(from object: TermType?) { 62 | guard let object = object else { return nil } 63 | self.init(from: object) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Errors/SwiftyPressError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftyPressError.swift 3 | // SwiftyPress 4 | // 5 | // Created by Basem Emara on 2018-06-01. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | public enum SwiftyPressError: Error { 10 | case duplicateFailure 11 | case nonExistent 12 | case incomplete 13 | case unauthorized 14 | case noInternet 15 | case timeout 16 | case parseFailure(Error?) 17 | case databaseFailure(Error?) 18 | case cacheFailure(Error?) 19 | case serverFailure(Error?) 20 | case requestFailure(Error?) 21 | case unknownReason(Error?) 22 | } 23 | 24 | public extension SwiftyPressError { 25 | 26 | /// Get the localized description for this error. 27 | var localizedDescription: String { 28 | switch self { 29 | case .duplicateFailure: 30 | return .localized(.duplicateFailureErrorMessage) 31 | case .nonExistent: 32 | return .localized(.nonExistentErrorMessage) 33 | case .incomplete: 34 | return .localized(.genericIncompleteFormErrorMessage) 35 | case .unauthorized: 36 | return .localized(.unauthorizedErrorMessage) 37 | case .noInternet: 38 | return .localized(.noInternetErrorMessage) 39 | case .timeout: 40 | return .localized(.serverTimeoutErrorMessage) 41 | case .parseFailure: 42 | return .localized(.parseFailureErrorMessage) 43 | case .databaseFailure: 44 | return .localized(.databaseFailureErrorMessage) 45 | case .cacheFailure: 46 | return .localized(.cacheFailureErrorMessage) 47 | case .serverFailure: 48 | return .localized(.serverFailureErrorMessage) 49 | case .requestFailure: 50 | return .localized(.badRequestErrorMessage) 51 | case .unknownReason: 52 | return .localized(.unknownReasonErrorMessage) 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Repositories/Taxonomy/Models/Term.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Term.swift 3 | // SwiftyPress 4 | // 5 | // Created by Basem Emara on 2018-06-03. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | // MARK: - Protocol 10 | 11 | public protocol TermType { 12 | var id: Int { get } 13 | var parentID: Int { get } 14 | var slug: String { get } 15 | var name: String { get } 16 | var content: String? { get } 17 | var taxonomy: Taxonomy { get } 18 | var count: Int { get } 19 | } 20 | 21 | // MARK: - Model 22 | 23 | public struct Term: TermType, Identifiable, Codable, Equatable { 24 | public let id: Int 25 | public let parentID: Int 26 | public let slug: String 27 | public let name: String 28 | public let content: String? 29 | public let taxonomy: Taxonomy 30 | public let count: Int 31 | } 32 | 33 | // MARK: - Codable 34 | 35 | private extension Term { 36 | 37 | enum CodingKeys: String, CodingKey { 38 | case id 39 | case parentID = "parent" 40 | case slug 41 | case name 42 | case content 43 | case taxonomy 44 | case count 45 | } 46 | } 47 | 48 | // MARK: - Conversions 49 | 50 | extension Term { 51 | 52 | /// For converting to one type to another. 53 | /// 54 | /// - Parameter object: An instance of term type. 55 | init(from object: TermType) { 56 | self.init( 57 | id: object.id, 58 | parentID: object.parentID, 59 | slug: object.slug, 60 | name: object.name, 61 | content: object.content, 62 | taxonomy: object.taxonomy, 63 | count: object.count 64 | ) 65 | } 66 | 67 | /// For converting to one type to another. 68 | /// 69 | /// - Parameter object: An instance of term type. 70 | init?(from object: TermType?) { 71 | guard let object = object else { return nil } 72 | self.init(from: object) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Repositories/Media/Services/MediaFileCache.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MediaFileCache.swift 3 | // SwiftyPress 4 | // 5 | // Created by Basem Emara on 2018-06-04. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | public struct MediaFileCache: MediaCache { 10 | private let seedService: DataSeed 11 | 12 | init(seedService: DataSeed) { 13 | self.seedService = seedService 14 | } 15 | } 16 | 17 | public extension MediaFileCache { 18 | 19 | func fetch(id: Int, completion: @escaping (Result) -> Void) { 20 | seedService.fetch { 21 | guard case let .success(item) = $0 else { 22 | completion(.failure($0.error ?? .unknownReason(nil))) 23 | return 24 | } 25 | 26 | // Find match 27 | guard let model = item.media.first(where: { $0.id == id }) else { 28 | completion(.failure(.nonExistent)) 29 | return 30 | } 31 | 32 | completion(.success(model)) 33 | } 34 | } 35 | } 36 | 37 | public extension MediaFileCache { 38 | 39 | func fetch(ids: Set, completion: @escaping (Result<[Media], SwiftyPressError>) -> Void) { 40 | seedService.fetch { 41 | guard case let .success(items) = $0 else { 42 | completion(.failure($0.error ?? .unknownReason(nil))) 43 | return 44 | } 45 | 46 | let model = ids.reduce(into: [Media]()) { result, next in 47 | guard let element = items.media.first(where: { $0.id == next }) else { return } 48 | result.append(element) 49 | } 50 | 51 | completion(.success(model)) 52 | } 53 | } 54 | } 55 | 56 | public extension MediaFileCache { 57 | 58 | func createOrUpdate(_ request: Media, completion: @escaping (Result) -> Void) { 59 | // Nothing to do 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Repositories/Author/Models/Author.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Author.swift 3 | // SwiftyPress 4 | // 5 | // Created by Basem Emara on 2018-06-03. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | import Foundation.NSDate 10 | 11 | // MARK: - Protocol 12 | 13 | public protocol AuthorType: Dateable { 14 | var id: Int { get } 15 | var name: String { get } 16 | var link: String { get } 17 | var avatar: String { get } 18 | var content: String { get } 19 | } 20 | 21 | // MARK: - Model 22 | 23 | public struct Author: AuthorType, Identifiable, Codable, Equatable { 24 | public let id: Int 25 | public let name: String 26 | public let link: String 27 | public let avatar: String 28 | public let content: String 29 | public let createdAt: Date 30 | public let modifiedAt: Date 31 | } 32 | 33 | // MARK: - Codable 34 | 35 | private extension Author { 36 | 37 | enum CodingKeys: String, CodingKey { 38 | case id 39 | case name 40 | case link 41 | case avatar 42 | case content = "description" 43 | case createdAt = "created" 44 | case modifiedAt = "modified" 45 | } 46 | } 47 | 48 | // MARK: - Conversions 49 | 50 | extension Author { 51 | 52 | /// For converting to one type to another. 53 | /// 54 | /// - Parameter object: An instance of author type. 55 | init(from object: AuthorType) { 56 | self.init( 57 | id: object.id, 58 | name: object.name, 59 | link: object.link, 60 | avatar: object.avatar, 61 | content: object.content, 62 | createdAt: object.createdAt, 63 | modifiedAt: object.modifiedAt 64 | ) 65 | } 66 | 67 | /// For converting to one type to another. 68 | /// 69 | /// - Parameter object: An instance of author type. 70 | init?(from object: AuthorType?) { 71 | guard let object = object else { return nil } 72 | self.init(from: object) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Extensions/Realm.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Realm.swift 3 | // SwiftyPress 4 | // 5 | // Created by Basem Emara on 2018-10-17. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | import Foundation.NSPredicate 10 | import RealmSwift 11 | 12 | extension Realm { 13 | 14 | /// Adds or updates an optional object into the Realm. 15 | /// 16 | /// - Parameters: 17 | /// - object: The object to be added to this Realm. 18 | /// - update: If `true`, the Realm will try to find 19 | /// an existing copy of the object (with the same 20 | /// primary key), and update it. Otherwise, the 21 | /// object will be added. 22 | func add(_ object: Object?, update: Bool) { 23 | guard let object = object else { return } 24 | add(object, update: update ? .modified : .error) 25 | } 26 | } 27 | 28 | extension Realm { 29 | 30 | /// Retrieves the instances of a given object type with the given primary keys from the Realm. 31 | /// 32 | /// - Parameters: 33 | /// - type: The type of the objects to be returned. 34 | /// - keys: The primary keys of the desired objects. 35 | /// - Returns: A Results containing the objects. 36 | func objects(_ ofType: T.Type, forPrimaryKeys: Set) -> Results { 37 | guard let primaryKey = ofType.primaryKey(), !primaryKey.isEmpty else { 38 | // Create empty set 39 | return objects(ofType).filter(NSPredicate(value: false)) 40 | } 41 | 42 | return objects(ofType).filter("\(primaryKey) IN %@", forPrimaryKeys) 43 | } 44 | } 45 | 46 | extension Results { 47 | 48 | /// Returns a subsequence, up to the specified maximum length, containing the initial elements of the transformed collection. 49 | func prefixMap(_ maxLength: Int?, _ transform: (Element) -> T) -> [T] { 50 | guard let maxLength = maxLength else { return map(transform) } 51 | guard maxLength > 0 else { return [] } 52 | return prefix(maxLength).map(transform) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Repositories/Media/Models/Media.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Media.swift 3 | // SwiftyPress 4 | // 5 | // Created by Basem Emara on 2018-06-03. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | // MARK: - Protocol 10 | 11 | public protocol MediaType { 12 | var id: Int { get } 13 | var link: String { get } 14 | var width: Int { get } 15 | var height: Int { get } 16 | var thumbnailLink: String { get } 17 | var thumbnailWidth: Int { get } 18 | var thumbnailHeight: Int { get } 19 | } 20 | 21 | // MARK: - Model 22 | 23 | public struct Media: MediaType, Identifiable, Codable, Equatable { 24 | public let id: Int 25 | public let link: String 26 | public let width: Int 27 | public let height: Int 28 | public let thumbnailLink: String 29 | public let thumbnailWidth: Int 30 | public let thumbnailHeight: Int 31 | } 32 | 33 | // MARK: - Codable 34 | 35 | private extension Media { 36 | 37 | enum CodingKeys: String, CodingKey { 38 | case id 39 | case link 40 | case width 41 | case height 42 | case thumbnailLink = "thumbnail_link" 43 | case thumbnailWidth = "thumbnail_width" 44 | case thumbnailHeight = "thumbnail_height" 45 | } 46 | } 47 | 48 | // MARK: - Conversions 49 | 50 | extension Media { 51 | 52 | /// For converting to one type to another. 53 | /// 54 | /// - Parameter object: An instance of media type. 55 | init(from object: MediaType) { 56 | self.init( 57 | id: object.id, 58 | link: object.link, 59 | width: object.width, 60 | height: object.height, 61 | thumbnailLink: object.thumbnailLink, 62 | thumbnailWidth: object.thumbnailWidth, 63 | thumbnailHeight: object.thumbnailHeight 64 | ) 65 | } 66 | 67 | /// For converting to one type to another. 68 | /// 69 | /// - Parameter object: An instance of media type. 70 | init?(from object: MediaType?) { 71 | guard let object = object else { return nil } 72 | self.init(from: object) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Views/UIKit/Controls/DataViews/TermsDataView/Cells/TermTableViewCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TagTableViewCell.swift 3 | // Basem Emara 4 | // 5 | // Created by Basem Emara on 2018-09-26. 6 | // Copyright © 2018 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | public final class TermTableViewCell: UITableViewCell { 12 | 13 | private let nameLabel = ThemedLabel().apply { 14 | $0.font = .preferredFont(forTextStyle: .body) 15 | $0.numberOfLines = 1 16 | } 17 | 18 | private let countLabel = ThemedFootnote().apply { 19 | $0.font = .preferredFont(forTextStyle: .footnote) 20 | $0.numberOfLines = 1 21 | } 22 | 23 | public override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { 24 | super.init(style: style, reuseIdentifier: reuseIdentifier) 25 | prepare() 26 | } 27 | 28 | @available(*, unavailable) 29 | public required init?(coder: NSCoder) { nil } 30 | } 31 | 32 | // MARK: - Setup 33 | 34 | private extension TermTableViewCell { 35 | 36 | func prepare() { 37 | separatorInset = UIEdgeInsets(top: 0, left: 15, bottom: 0, right: 15) 38 | 39 | let stackView = UIStackView(arrangedSubviews: [ 40 | nameLabel, 41 | countLabel.apply { 42 | $0.setContentHuggingPriority(.defaultHigh, for: .horizontal) 43 | } 44 | ]).apply { 45 | $0.axis = .horizontal 46 | $0.spacing = 8 47 | } 48 | 49 | let view = ThemedView().apply { 50 | $0.addSubview(stackView) 51 | } 52 | 53 | addSubview(view) 54 | view.edges(to: self) 55 | 56 | stackView.edges( 57 | to: view, 58 | insets: UIEdgeInsets(top: 8, left: 16, bottom: 8, right: 16), 59 | safeArea: true 60 | ) 61 | } 62 | } 63 | 64 | // MARK: - Delegates 65 | 66 | extension TermTableViewCell: TermsDataViewCell { 67 | 68 | public func load(_ model: TermsDataViewModel) { 69 | nameLabel.text = model.name 70 | countLabel.text = "(\(model.count))" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Views/UIKit/Controls/DataViews/PostsDataView/Cells/SimplePostTableViewCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SimplePostTableViewCell.swift 3 | // Basem Emara 4 | // 5 | // Created by Basem Emara on 2018-10-07. 6 | // Copyright © 2018 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | public final class SimplePostTableViewCell: UITableViewCell { 12 | 13 | private let titleLabel = ThemedHeadline().apply { 14 | $0.font = .preferredFont(forTextStyle: .body) 15 | $0.adjustsFontForContentSizeCategory = true 16 | $0.numberOfLines = 2 17 | } 18 | 19 | private let detailLabel = ThemedCaption().apply { 20 | $0.font = .preferredFont(forTextStyle: .footnote) 21 | $0.numberOfLines = 1 22 | } 23 | 24 | public override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { 25 | super.init(style: style, reuseIdentifier: reuseIdentifier) 26 | prepare() 27 | } 28 | 29 | @available(*, unavailable) 30 | public required init?(coder: NSCoder) { nil } 31 | } 32 | 33 | // MARK: - Setup 34 | 35 | private extension SimplePostTableViewCell { 36 | 37 | func prepare() { 38 | accessoryType = .disclosureIndicator 39 | 40 | let stackView = UIStackView(arrangedSubviews: [ 41 | titleLabel.apply { 42 | $0.setContentHuggingPriority(.defaultHigh, for: .vertical) 43 | }, 44 | detailLabel 45 | ]).apply { 46 | $0.axis = .vertical 47 | $0.spacing = 6 48 | } 49 | 50 | let view = ThemedView().apply { 51 | $0.addSubview(stackView) 52 | } 53 | 54 | addSubview(view) 55 | view.edges(to: self) 56 | 57 | stackView.edges( 58 | to: view, 59 | insets: UIEdgeInsets(top: 12, left: 20, bottom: 12, right: 36), 60 | safeArea: true 61 | ) 62 | } 63 | } 64 | 65 | // MARK: - Delegates 66 | 67 | extension SimplePostTableViewCell: PostsDataViewCell { 68 | 69 | public func load(_ model: PostsDataViewModel) { 70 | titleLabel.text = model.title 71 | detailLabel.text = model.date 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Preferences/Constants/Services/ConstantsStaticService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ConstantsStaticService.swift 3 | // SwiftyPress 4 | // 5 | // Created by Basem Emara on 2018-10-03. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | import Foundation.NSURL 10 | import ZamzamCore 11 | 12 | public struct ConstantsStaticService: ConstantsService { 13 | public let isDebug: Bool 14 | public let itunesName: String 15 | public let itunesID: String 16 | public let baseURL: URL 17 | public let baseREST: String 18 | public let wpREST: String 19 | public let email: String 20 | public let privacyURL: String 21 | public let disclaimerURL: String? 22 | public let styleSheet: String 23 | public let googleAnalyticsID: String? 24 | public let featuredCategoryID: Int 25 | public let defaultFetchModifiedLimit: Int 26 | public let taxonomies: [String] 27 | public let postMetaKeys: [String] 28 | public let minLogLevel: LogAPI.Level 29 | 30 | public init( 31 | isDebug: Bool, 32 | itunesName: String, 33 | itunesID: String, 34 | baseURL: URL, 35 | baseREST: String, 36 | wpREST: String, 37 | email: String, 38 | privacyURL: String, 39 | disclaimerURL: String?, 40 | styleSheet: String, 41 | googleAnalyticsID: String?, 42 | featuredCategoryID: Int, 43 | defaultFetchModifiedLimit: Int, 44 | taxonomies: [String], 45 | postMetaKeys: [String], 46 | minLogLevel: LogAPI.Level 47 | ) { 48 | self.isDebug = isDebug 49 | self.itunesName = itunesName 50 | self.itunesID = itunesID 51 | self.baseURL = baseURL 52 | self.baseREST = baseREST 53 | self.wpREST = wpREST 54 | self.email = email 55 | self.privacyURL = privacyURL 56 | self.disclaimerURL = disclaimerURL 57 | self.styleSheet = styleSheet 58 | self.googleAnalyticsID = googleAnalyticsID 59 | self.featuredCategoryID = featuredCategoryID 60 | self.defaultFetchModifiedLimit = defaultFetchModifiedLimit 61 | self.taxonomies = taxonomies 62 | self.postMetaKeys = postMetaKeys 63 | self.minLogLevel = minLogLevel 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Repositories/Taxonomy/Models/Taxonomy.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Taxonomy.swift 3 | // SwiftyPress 4 | // 5 | // Created by Basem Emara on 2018-06-02. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | public enum Taxonomy: Codable, Equatable, Hashable { 10 | case category 11 | case tag 12 | case other(String) 13 | } 14 | 15 | // MARK: - Conversions 16 | 17 | extension Taxonomy: RawRepresentable { 18 | 19 | private enum CodingKeys: String, CodingKey { 20 | case category 21 | case tag = "post_tag" 22 | case other 23 | } 24 | 25 | public var rawValue: String { 26 | switch self { 27 | case .category: 28 | return CodingKeys.category.rawValue 29 | case .tag: 30 | return CodingKeys.tag.rawValue 31 | case .other(let item): 32 | return item 33 | } 34 | } 35 | 36 | public init?(rawValue: String) { 37 | switch rawValue { 38 | case CodingKeys.category.rawValue: 39 | self = .category 40 | case CodingKeys.tag.rawValue: 41 | self = .tag 42 | default: 43 | self = .other(rawValue) 44 | } 45 | } 46 | } 47 | 48 | // MARK: - Helpers 49 | 50 | public extension Taxonomy { 51 | 52 | var localized: String { 53 | switch self { 54 | case .category: 55 | return .localized(.categorySection) 56 | case .tag: 57 | return .localized(.tagSection) 58 | case .other(let item): 59 | return .localized(.taxonomy(for: item)) 60 | } 61 | } 62 | } 63 | 64 | //extension Taxonomy: Equatable { 65 | // 66 | // public static func == (lhs: Taxonomy, rhs: Taxonomy) -> Bool { 67 | // switch (lhs, rhs) { 68 | // case (.category, .category): 69 | // return true 70 | // case (.tag, .tag): 71 | // return true 72 | // case (let .other(lhsValue), let .other(rhsValue)): 73 | // return lhsValue == rhsValue 74 | // default: 75 | // return false 76 | // } 77 | // } 78 | //} 79 | // 80 | //extension Taxonomy: Hashable { 81 | // 82 | // public func hash(into hasher: inout Hasher) { 83 | // hasher.combine(rawValue) 84 | // } 85 | //} 86 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Repositories/Post/PostAPI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PostAPI.swift 3 | // SwiftyPress 4 | // 5 | // Created by Basem Emara on 2018-06-03. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | public protocol PostService { 10 | func fetch(id: Int, with request: PostAPI.ItemRequest, completion: @escaping (Result) -> Void) 11 | } 12 | 13 | // MARK: - Cache 14 | 15 | public protocol PostCache { 16 | func fetch(id: Int, completion: @escaping (Result) -> Void) 17 | func fetch(slug: String, completion: @escaping (Result) -> Void) 18 | 19 | func fetch(with request: PostAPI.FetchRequest, completion: @escaping (Result<[Post], SwiftyPressError>) -> Void) 20 | func fetchPopular(with request: PostAPI.FetchRequest, completion: @escaping (Result<[Post], SwiftyPressError>) -> Void) 21 | 22 | func fetch(ids: Set, completion: @escaping (Result<[Post], SwiftyPressError>) -> Void) 23 | func fetch(byTermIDs ids: Set, with request: PostAPI.FetchRequest, completion: @escaping (Result<[Post], SwiftyPressError>) -> Void) 24 | 25 | func search(with request: PostAPI.SearchRequest, completion: @escaping (Result<[Post], SwiftyPressError>) -> Void) 26 | func getID(bySlug slug: String) -> Int? 27 | 28 | func createOrUpdate(_ request: ExtendedPost, completion: @escaping (Result) -> Void) 29 | } 30 | 31 | // MARK: Namespace 32 | 33 | public enum PostAPI { 34 | 35 | public struct FetchRequest { 36 | let maxLength: Int? 37 | 38 | public init(maxLength: Int? = nil) { 39 | self.maxLength = maxLength 40 | } 41 | } 42 | 43 | public struct ItemRequest { 44 | let taxonomies: [String] 45 | let postMetaKeys: [String] 46 | } 47 | 48 | public enum SearchScope { 49 | case all 50 | case title 51 | case content 52 | case terms 53 | } 54 | 55 | public struct SearchRequest { 56 | let query: String 57 | let scope: SearchScope 58 | let maxLength: Int? 59 | 60 | public init(query: String, scope: SearchScope, maxLength: Int? = nil) { 61 | self.query = query 62 | self.scope = scope 63 | self.maxLength = maxLength 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Extensions/LogRepository.swift: -------------------------------------------------------------------------------- 1 | // 2 | // APISession.swift 3 | // SwiftyPress 4 | // 5 | // Created by Basem Emara on 2018-10-09. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | import Foundation.NSURLRequest 10 | import ZamzamCore 11 | 12 | extension LogRepository { 13 | 14 | /// Log URL request which help during debugging (low priority; not written to file) 15 | func request( 16 | _ request: URLRequest?, 17 | isDebug: Bool, 18 | file: String = #fileID, 19 | function: String = #function, 20 | line: Int = #line 21 | ) { 22 | guard isDebug else { return } 23 | 24 | let message: String = { 25 | var output = "Request: {\n" 26 | guard let request = request else { return "Request: empty" } 27 | 28 | if let value = request.url?.absoluteString { 29 | output += "\turl: \(value),\n" 30 | } 31 | 32 | if let value = request.httpMethod { 33 | output += "\tmethod: \(value),\n" 34 | } 35 | 36 | if let value = request.allHTTPHeaderFields?.scrubbed { 37 | output += "\theaders: \(value)\n" 38 | } 39 | 40 | output += "}" 41 | return output 42 | }() 43 | 44 | debug(message, file: file, function: function, line: line, context: [:]) 45 | } 46 | 47 | /// Log HTTP response which help during debugging (low priority; not written to file) 48 | func response( 49 | _ response: NetworkAPI.Response?, 50 | url: String?, 51 | isDebug: Bool, 52 | file: String = #fileID, 53 | function: String = #function, 54 | line: Int = #line 55 | ) { 56 | guard isDebug else { return } 57 | 58 | let message: String = { 59 | var message = "Response: {\n" 60 | 61 | if let value = url { 62 | message += "\turl: \(value),\n" 63 | } 64 | 65 | if let response = response { 66 | message += "\tstatus: \(response.statusCode),\n" 67 | message += "\theaders: \(response.headers.scrubbed)\n" 68 | } 69 | 70 | message += "}" 71 | return message 72 | }() 73 | 74 | debug(message, file: file, function: function, line: line, context: [:]) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Enums/Social.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Social.swift 3 | // SwiftyPress 4 | // 5 | // Created by Basem Emara on 2018-10-08. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | import Foundation.NSURL 10 | 11 | public enum Social: String, CaseIterable { 12 | case twitter 13 | case linkedIn 14 | case github 15 | case pinterest 16 | case instagram 17 | case youtube 18 | case facebook 19 | case email 20 | } 21 | 22 | public extension Social { 23 | 24 | /// Web address to the social network profile 25 | /// 26 | /// - Parameter username: The profile username used for the path. 27 | /// - Returns: Constructed web address. 28 | func link(for username: String) -> String { 29 | switch self { 30 | case .twitter: 31 | return "https://twitter.com/\(username)" 32 | case .linkedIn: 33 | return "https://www.linkedin.com/in/\(username)" 34 | case .github: 35 | return "https://github.com/\(username)" 36 | case .pinterest: 37 | return "http://pinterest.com/\(username)" 38 | case .instagram: 39 | return "http://instagram.com/\(username)" 40 | case .youtube: 41 | return "https://youtube.com/\(username)" 42 | case .facebook: 43 | return "https://facebook.com/\(username)" 44 | case .email: 45 | return "mailto:\(username)" 46 | } 47 | } 48 | } 49 | 50 | public extension Social { 51 | 52 | /// URL scheme to the social network app profile 53 | /// 54 | /// - Parameter username: The profile username used for the path. 55 | /// - Returns: Constructed URL scheme. 56 | func shortcut(for username: String) -> URL? { 57 | switch self { 58 | case .twitter: 59 | return URL(string: "twitter://user?screen_name=\(username)") 60 | case .linkedIn: 61 | return URL(string: "linkedin://profile/\(username)") 62 | case .github: 63 | return URL(string: "https://github.com/\(username)") 64 | case .pinterest: 65 | return URL(string: "pinterest://user/\(username)") 66 | case .instagram: 67 | return URL(string: "instagram://user?username=\(username)") 68 | case .youtube: 69 | return URL(string: "youtube://www.youtube.com/user/\(username)") 70 | case .facebook: 71 | return URL(string: "fb://profile?id=\(username)") 72 | case .email: 73 | return URL(string: "mailto:\(username)") 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Views/UIKit/Controls/EmptyPlaceholderView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EmptyPlaceholderView.swift 3 | // SwiftyPress 4 | // 5 | // Created by Basem Emara on 2020-04-23. 6 | // Copyright © 2020 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | #if os(iOS) 10 | import UIKit 11 | 12 | public class EmptyPlaceholderView: UIView { 13 | 14 | private lazy var image = UIImageView(imageNamed: .emptyPlaceholder).apply { 15 | $0.contentMode = .scaleAspectFit 16 | } 17 | 18 | private lazy var label = ThemedHeadline().apply { 19 | $0.font = .preferredFont(forTextStyle: .headline) 20 | $0.textAlignment = .center 21 | $0.numberOfLines = 3 22 | } 23 | 24 | public init(text: String) { 25 | super.init(frame: .zero) 26 | self.label.text = text 27 | self.prepare() 28 | } 29 | 30 | @available(*, unavailable) 31 | public required init?(coder: NSCoder) { nil } 32 | } 33 | 34 | private extension EmptyPlaceholderView { 35 | 36 | func prepare() { 37 | // Configure controls 38 | backgroundColor = .clear 39 | 40 | // Compose layout 41 | let stackView = UIStackView(arrangedSubviews: [ 42 | UIView().apply { 43 | $0.backgroundColor = .clear 44 | $0.addSubview(image) 45 | }, 46 | label 47 | ]).apply { 48 | $0.axis = .vertical 49 | $0.spacing = 10 50 | } 51 | 52 | let contentView = UIView().apply { 53 | $0.addSubview(stackView) 54 | } 55 | 56 | addSubview(contentView) 57 | contentView.edges(to: self) 58 | 59 | stackView.translatesAutoresizingMaskIntoConstraints = false 60 | stackView.widthAnchor.constraint(equalTo: contentView.widthAnchor, multiplier: 0.7).isActive = true 61 | stackView.heightAnchor.constraint(lessThanOrEqualTo: contentView.heightAnchor).isActive = true 62 | stackView.center() 63 | 64 | if let superview = image.superview { 65 | image.translatesAutoresizingMaskIntoConstraints = false 66 | image.topAnchor.constraint(equalTo: superview.topAnchor).isActive = true 67 | image.bottomAnchor.constraint(equalTo: superview.bottomAnchor).isActive = true 68 | image.centerXAnchor.constraint(equalTo: superview.centerXAnchor).isActive = true 69 | image.widthAnchor.constraint(equalToConstant: 100).isActive = true 70 | image.aspectRatioSize() 71 | } 72 | } 73 | } 74 | 75 | #endif 76 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Repositories/Author/AuthorAPI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthorAPI.swift 3 | // SwiftyPress 4 | // 5 | // Created by Basem Emara on 2018-06-04. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | import ZamzamCore 10 | 11 | // MARK: - Services 12 | 13 | public protocol AuthorService { 14 | func fetch(with request: AuthorAPI.FetchRequest, completion: @escaping (Result) -> Void) 15 | } 16 | 17 | // MARK: - Cache 18 | 19 | public protocol AuthorCache { 20 | func fetch(with request: AuthorAPI.FetchRequest, completion: @escaping (Result) -> Void) 21 | func createOrUpdate(_ request: Author, completion: @escaping (Result) -> Void) 22 | 23 | func subscribe( 24 | with request: AuthorAPI.FetchRequest, 25 | in cancellable: inout Cancellable?, 26 | change block: @escaping (ChangeResult) -> Void 27 | ) 28 | } 29 | 30 | // MARK: - Namespace 31 | 32 | public enum AuthorAPI { 33 | 34 | public struct FetchRequest: Hashable { 35 | public let id: Int 36 | 37 | public init(id: Int) { 38 | self.id = id 39 | } 40 | } 41 | 42 | public struct FetchCancellable { 43 | private let service: AuthorService 44 | private let cache: AuthorCache? 45 | private let request: FetchRequest 46 | private let block: (ChangeResult) -> Void 47 | 48 | init( 49 | service: AuthorService, 50 | cache: AuthorCache?, 51 | request: FetchRequest, 52 | change block: @escaping (ChangeResult) -> Void 53 | ) { 54 | self.service = service 55 | self.cache = cache 56 | self.request = request 57 | self.block = block 58 | } 59 | 60 | /// Stores the cancellable object for subscriptions to be delievered during its lifetime. 61 | /// 62 | /// A subscription is automatically cancelled when the object is deinitialized. 63 | /// 64 | /// - Parameter cancellable: A subscription token which must be held for as long as you want updates to be delivered. 65 | public func store(in cancellable: inout Cancellable?) { 66 | guard let cache = cache else { 67 | service.fetch(with: request, completion: { $0(self.block) }) 68 | return 69 | } 70 | 71 | cache.subscribe(with: request, in: &cancellable, change: block) 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Views/UIKit/Controls/DataViews/PostsDataView/PostsDataViewDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PostsDataViewDelegate.swift 3 | // Basem Emara 4 | // 5 | // Created by Basem Emara on 2018-06-21. 6 | // Copyright © 2018 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | #if os(iOS) 10 | import Foundation 11 | import UIKit 12 | import ZamzamCore 13 | import ZamzamUI 14 | 15 | public protocol PostsDataViewDelegate: AnyObject { 16 | func postsDataView(didSelect model: PostsDataViewModel, at indexPath: IndexPath, from dataView: DataViewable) 17 | func postsDataView(toggleFavorite model: PostsDataViewModel) 18 | func postsDataViewNumberOfSections(in dataView: DataViewable) -> Int 19 | func postsDataViewDidReloadData() 20 | 21 | func postsDataView(leadingSwipeActionsFor model: PostsDataViewModel, at indexPath: IndexPath, from tableView: UITableView) -> UISwipeActionsConfiguration? 22 | 23 | func postsDataView(trailingSwipeActionsFor model: PostsDataViewModel, at indexPath: IndexPath, from tableView: UITableView) -> UISwipeActionsConfiguration? 24 | 25 | @available(iOS 13, *) 26 | func postsDataView(contextMenuConfigurationFor model: PostsDataViewModel, at indexPath: IndexPath, point: CGPoint, from dataView: DataViewable) -> UIContextMenuConfiguration? 27 | 28 | @available(iOS 13, *) 29 | func postsDataView(didPerformPreviewActionFor model: PostsDataViewModel, from dataView: DataViewable) 30 | 31 | func postsDataViewWillBeginDragging(_ scrollView: UIScrollView) 32 | func postsDataViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) 33 | } 34 | 35 | // Optional conformance 36 | public extension PostsDataViewDelegate { 37 | func postsDataView(toggleFavorite model: PostsDataViewModel) {} 38 | func postsDataViewNumberOfSections(in dataView: DataViewable) -> Int { return 1 } 39 | func postsDataViewDidReloadData() {} 40 | 41 | func postsDataView(leadingSwipeActionsFor model: PostsDataViewModel, at indexPath: IndexPath, from tableView: UITableView) -> UISwipeActionsConfiguration? { 42 | UISwipeActionsConfiguration() 43 | } 44 | 45 | func postsDataView(trailingSwipeActionsFor model: PostsDataViewModel, at indexPath: IndexPath, from tableView: UITableView) -> UISwipeActionsConfiguration? { 46 | UISwipeActionsConfiguration() 47 | } 48 | 49 | @available(iOS 13, *) 50 | func postsDataView(contextMenuConfigurationFor model: PostsDataViewModel, at indexPath: IndexPath, point: CGPoint, from dataView: DataViewable) -> UIContextMenuConfiguration? { 51 | nil 52 | } 53 | 54 | @available(iOS 13, *) 55 | func postsDataView(didPerformPreviewActionFor model: PostsDataViewModel, from dataView: DataViewable) {} 56 | 57 | func postsDataViewWillBeginDragging(_ scrollView: UIScrollView) {} 58 | func postsDataViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) {} 59 | } 60 | #endif 61 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Repositories/Post/Models/Post.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Post.swift 3 | // SwiftyPress 4 | // 5 | // Created by Basem Emara on 2018-05-30. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | import Foundation.NSDate 10 | 11 | // MARK: - Protocol 12 | 13 | public protocol PostType: Dateable { 14 | var id: Int { get } 15 | var slug: String { get } 16 | var type: String { get } 17 | var title: String { get } 18 | var content: String { get } 19 | var excerpt: String { get } 20 | var link: String { get } 21 | var commentCount: Int { get } 22 | var authorID: Int { get } 23 | var mediaID: Int? { get } 24 | var terms: [Int] { get } 25 | var meta: [String: String] { get } 26 | } 27 | 28 | // MARK: - Model 29 | 30 | public struct Post: PostType, Identifiable, Codable, Equatable { 31 | public let id: Int 32 | public let slug: String 33 | public let type: String 34 | public let title: String 35 | public let content: String 36 | public let excerpt: String 37 | public let link: String 38 | public let commentCount: Int 39 | public let authorID: Int 40 | public let mediaID: Int? 41 | public let terms: [Int] 42 | public let meta: [String: String] 43 | public let createdAt: Date 44 | public let modifiedAt: Date 45 | } 46 | 47 | // MARK: - Codable 48 | 49 | private extension Post { 50 | 51 | enum CodingKeys: String, CodingKey { 52 | case id 53 | case slug 54 | case type 55 | case title 56 | case content 57 | case excerpt 58 | case link 59 | case commentCount = "comment_count" 60 | case authorID = "author" 61 | case mediaID = "featured_media" 62 | case terms 63 | case meta 64 | case createdAt = "created" 65 | case modifiedAt = "modified" 66 | } 67 | } 68 | 69 | // MARK: - Conversions 70 | 71 | extension Post { 72 | 73 | /// For converting to one type to another. 74 | /// 75 | /// - Parameter object: An instance of post type. 76 | init(from object: PostType) { 77 | self.init( 78 | id: object.id, 79 | slug: object.slug, 80 | type: object.type, 81 | title: object.title, 82 | content: object.content, 83 | excerpt: object.excerpt, 84 | link: object.link, 85 | commentCount: object.commentCount, 86 | authorID: object.authorID, 87 | mediaID: object.mediaID, 88 | terms: object.terms, 89 | meta: object.meta, 90 | createdAt: object.createdAt, 91 | modifiedAt: object.modifiedAt 92 | ) 93 | } 94 | 95 | /// For converting to one type to another. 96 | /// 97 | /// - Parameter object: An instance of post type. 98 | init?(from object: PostType?) { 99 | guard let object = object else { return nil } 100 | self.init(from: object) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Views/UIKit/Controls/DataViews/PostsDataView/Cells/LatestPostCollectionViewCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PostCollectionViewCell.swift 3 | // Basem Emara 4 | // 5 | // Created by Basem Emara on 2018-06-20. 6 | // Copyright © 2018 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | public final class LatestPostCollectionViewCell: UICollectionViewCell { 12 | 13 | private let titleLabel = ThemedHeadline().apply { 14 | $0.font = .preferredFont(forTextStyle: .headline) 15 | $0.adjustsFontForContentSizeCategory = true 16 | $0.numberOfLines = 3 17 | } 18 | 19 | private let summaryLabel = ThemedSubhead().apply { 20 | $0.font = .preferredFont(forTextStyle: .subheadline) 21 | $0.numberOfLines = 0 22 | } 23 | 24 | private let featuredImage = UIImageView(imageNamed: .placeholder).apply { 25 | $0.contentMode = .scaleAspectFill 26 | $0.clipsToBounds = true 27 | } 28 | 29 | public override init(frame: CGRect) { 30 | super.init(frame: frame) 31 | prepare() 32 | } 33 | 34 | @available(*, unavailable) 35 | public required init?(coder: NSCoder) { nil } 36 | } 37 | 38 | // MARK: - Setup 39 | 40 | private extension LatestPostCollectionViewCell { 41 | 42 | func prepare() { 43 | let stackView = UIStackView(arrangedSubviews: [ 44 | titleLabel.apply { 45 | $0.setContentHuggingPriority(.defaultHigh, for: .vertical) 46 | }, 47 | summaryLabel 48 | ]).apply { 49 | $0.axis = .vertical 50 | $0.spacing = 10 51 | } 52 | 53 | let view = ThemedView().apply { 54 | $0.addSubview(featuredImage) 55 | $0.addSubview(stackView) 56 | } 57 | 58 | addSubview(view) 59 | view.edges(to: self) 60 | 61 | featuredImage.translatesAutoresizingMaskIntoConstraints = false 62 | featuredImage.topAnchor.constraint(equalTo: view.topAnchor).isActive = true 63 | featuredImage.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true 64 | featuredImage.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true 65 | featuredImage.heightAnchor.constraint(equalTo: view.heightAnchor, multiplier: 0.4, constant: 0).isActive = true 66 | 67 | stackView.translatesAutoresizingMaskIntoConstraints = false 68 | stackView.topAnchor.constraint(equalTo: featuredImage.bottomAnchor, constant: 20).isActive = true 69 | stackView.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor, constant: 8).isActive = true 70 | stackView.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor, constant: -8).isActive = true 71 | stackView.bottomAnchor.constraint(equalTo: view.layoutMarginsGuide.bottomAnchor, constant: -8).isActive = true 72 | } 73 | } 74 | 75 | // MARK: - Delegates 76 | 77 | extension LatestPostCollectionViewCell: PostsDataViewCell { 78 | 79 | public func load(_ model: PostsDataViewModel) { 80 | titleLabel.text = model.title 81 | summaryLabel.text = model.summary 82 | featuredImage.setImage(from: model.imageURL) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Repositories/Media/Services/MediaNetworkService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MediaNetworkService.swift 3 | // SwiftyPress 4 | // 5 | // Created by Basem Emara on 2019-05-17. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import ZamzamCore 11 | 12 | public struct MediaNetworkService: MediaService { 13 | private let networkRepository: NetworkRepository 14 | private let jsonDecoder: JSONDecoder 15 | private let constants: Constants 16 | private let log: LogRepository 17 | 18 | public init( 19 | networkRepository: NetworkRepository, 20 | jsonDecoder: JSONDecoder, 21 | constants: Constants, 22 | log: LogRepository 23 | ) { 24 | self.networkRepository = networkRepository 25 | self.jsonDecoder = jsonDecoder 26 | self.constants = constants 27 | self.log = log 28 | } 29 | } 30 | 31 | public extension MediaNetworkService { 32 | 33 | func fetch(id: Int, completion: @escaping (Result) -> Void) { 34 | let urlRequest: URLRequest = .readMedia(id: id, constants: constants) 35 | 36 | networkRepository.send(with: urlRequest) { 37 | // Handle errors 38 | guard case .success = $0 else { 39 | // Handle no existing data 40 | if $0.error?.statusCode == 404 { 41 | completion(.failure(.nonExistent)) 42 | return 43 | } 44 | 45 | self.log.error("An error occured while fetching the media: \(String(describing: $0.error)).") 46 | completion(.failure(SwiftyPressError(from: $0.error ?? .init(request: urlRequest)))) 47 | return 48 | } 49 | 50 | // Ensure available 51 | guard case let .success(item) = $0, let data = item.data else { 52 | completion(.failure(.nonExistent)) 53 | return 54 | } 55 | 56 | DispatchQueue.transform.async { 57 | do { 58 | // Type used for decoding the server payload 59 | struct ServerResponse: Decodable { 60 | let media: Media 61 | } 62 | 63 | // Parse response data 64 | let payload = try self.jsonDecoder.decode(ServerResponse.self, from: data) 65 | 66 | DispatchQueue.main.async { 67 | completion(.success(payload.media)) 68 | } 69 | } catch { 70 | self.log.error("An error occured while parsing the media: \(error).") 71 | DispatchQueue.main.async { completion(.failure(.parseFailure(error))) } 72 | return 73 | } 74 | } 75 | } 76 | } 77 | } 78 | 79 | // MARK: - Requests 80 | 81 | private extension URLRequest { 82 | 83 | static func readMedia(id: Int, constants: Constants) -> URLRequest { 84 | URLRequest( 85 | url: constants.baseURL 86 | .appendingPathComponent(constants.baseREST) 87 | .appendingPathComponent("media/\(id)"), 88 | method: .get 89 | ) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Repositories/Author/Services/AuthorNetworkService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthorNetworkService.swift 3 | // SwiftyPress 4 | // 5 | // Created by Basem Emara on 2019-05-17. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import ZamzamCore 11 | 12 | public struct AuthorNetworkService: AuthorService { 13 | private let networkRepository: NetworkRepository 14 | private let jsonDecoder: JSONDecoder 15 | private let constants: Constants 16 | private let log: LogRepository 17 | 18 | public init( 19 | networkRepository: NetworkRepository, 20 | jsonDecoder: JSONDecoder, 21 | constants: Constants, 22 | log: LogRepository 23 | ) { 24 | self.networkRepository = networkRepository 25 | self.jsonDecoder = jsonDecoder 26 | self.constants = constants 27 | self.log = log 28 | } 29 | } 30 | 31 | public extension AuthorNetworkService { 32 | 33 | func fetch(with request: AuthorAPI.FetchRequest, completion: @escaping (Result) -> Void) { 34 | let urlRequest: URLRequest = .readAuthor(id: request.id, constants: constants) 35 | 36 | networkRepository.send(with: urlRequest) { 37 | // Handle errors 38 | guard case .success = $0 else { 39 | // Handle no existing data 40 | if $0.error?.statusCode == 404 { 41 | completion(.failure(.nonExistent)) 42 | return 43 | } 44 | 45 | self.log.error("An error occured while fetching the author: \(String(describing: $0.error)).") 46 | completion(.failure(SwiftyPressError(from: $0.error ?? .init(request: urlRequest)))) 47 | return 48 | } 49 | 50 | // Ensure available 51 | guard case let .success(item) = $0, let data = item.data else { 52 | completion(.failure(.nonExistent)) 53 | return 54 | } 55 | 56 | DispatchQueue.transform.async { 57 | do { 58 | // Type used for decoding the server payload 59 | struct ServerResponse: Decodable { 60 | let author: Author 61 | } 62 | 63 | // Parse response data 64 | let payload = try self.jsonDecoder.decode(ServerResponse.self, from: data) 65 | 66 | DispatchQueue.main.async { 67 | completion(.success(payload.author)) 68 | } 69 | } catch { 70 | self.log.error("An error occured while parsing the author: \(error).") 71 | DispatchQueue.main.async { completion(.failure(.parseFailure(error))) } 72 | return 73 | } 74 | } 75 | } 76 | } 77 | } 78 | 79 | // MARK: - Requests 80 | 81 | private extension URLRequest { 82 | 83 | static func readAuthor(id: Int, constants: Constants) -> URLRequest { 84 | URLRequest( 85 | url: constants.baseURL 86 | .appendingPathComponent(constants.baseREST) 87 | .appendingPathComponent("author/\(id)"), 88 | method: .get 89 | ) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Views/UIKit/Controls/DataViews/PostsDataView/Cells/PostTableViewCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PostTableViewCell.swift 3 | // Basem Emara 4 | // 5 | // Created by Basem Emara on 2018-06-20. 6 | // Copyright © 2018 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import ZamzamUI 11 | 12 | public final class PostTableViewCell: UITableViewCell { 13 | 14 | private let titleLabel = ThemedHeadline().apply { 15 | $0.font = .preferredFont(forTextStyle: .headline) 16 | $0.adjustsFontForContentSizeCategory = true 17 | $0.numberOfLines = 2 18 | } 19 | 20 | private let summaryLabel = ThemedSubhead().apply { 21 | $0.font = .preferredFont(forTextStyle: .subheadline) 22 | $0.numberOfLines = 2 23 | } 24 | 25 | private let dateLabel = ThemedCaption().apply { 26 | $0.font = .preferredFont(forTextStyle: .footnote) 27 | $0.numberOfLines = 1 28 | } 29 | 30 | private let featuredImage = RoundedImageView(imageNamed: .placeholder) 31 | 32 | public override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { 33 | super.init(style: style, reuseIdentifier: reuseIdentifier) 34 | prepare() 35 | } 36 | 37 | @available(*, unavailable) 38 | public required init?(coder: NSCoder) { nil } 39 | } 40 | 41 | // MARK: - Setup 42 | 43 | private extension PostTableViewCell { 44 | 45 | func prepare() { 46 | separatorInset = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16) 47 | 48 | let stackView = UIStackView(arrangedSubviews: [ 49 | UIStackView(arrangedSubviews: [ 50 | titleLabel.apply { 51 | $0.setContentHuggingPriority(.defaultHigh, for: .vertical) 52 | }, 53 | summaryLabel, 54 | dateLabel.apply { 55 | $0.setContentHuggingPriority(.defaultLow, for: .vertical) 56 | } 57 | ]).apply { 58 | $0.axis = .vertical 59 | $0.spacing = 10 60 | }, 61 | UIView().apply { 62 | $0.addSubview(featuredImage) 63 | } 64 | ]).apply { 65 | $0.axis = .horizontal 66 | $0.spacing = 20 67 | } 68 | 69 | let view = ThemedView().apply { 70 | $0.addSubview(stackView) 71 | } 72 | 73 | addSubview(view) 74 | 75 | view.edges(to: self) 76 | stackView.edges(to: view, padding: 24, safeArea: true) 77 | 78 | featuredImage.aspectRatioSize() 79 | featuredImage.widthAnchor.constraint(equalToConstant: 70).isActive = true 80 | 81 | if let superview = featuredImage.superview { 82 | featuredImage.topAnchor.constraint(equalTo: superview.topAnchor).isActive = true 83 | featuredImage.leadingAnchor.constraint(equalTo: superview.leadingAnchor).isActive = true 84 | featuredImage.trailingAnchor.constraint(equalTo: superview.trailingAnchor).isActive = true 85 | } 86 | } 87 | } 88 | 89 | // MARK: - Delegates 90 | 91 | extension PostTableViewCell: PostsDataViewCell { 92 | 93 | public func load(_ model: PostsDataViewModel) { 94 | titleLabel.text = model.title 95 | summaryLabel.text = model.summary 96 | dateLabel.text = model.date 97 | featuredImage.setImage(from: model.imageURL) 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Repositories/Media/Services/MediaRealmCache.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MediaRealmCache.swift 3 | // SwiftyPress 4 | // 5 | // Created by Basem Emara on 2018-10-20. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import RealmSwift 11 | import ZamzamCore 12 | 13 | public struct MediaRealmCache: MediaCache { 14 | private let log: LogRepository 15 | 16 | public init(log: LogRepository) { 17 | self.log = log 18 | } 19 | } 20 | 21 | public extension MediaRealmCache { 22 | 23 | func fetch(id: Int, completion: @escaping (Result) -> Void) { 24 | DispatchQueue.database.async { 25 | let realm: Realm 26 | 27 | do { 28 | realm = try Realm() 29 | } catch { 30 | DispatchQueue.main.async { completion(.failure(.databaseFailure(error))) } 31 | return 32 | } 33 | 34 | guard let object = realm.object(ofType: MediaRealmObject.self, forPrimaryKey: id) else { 35 | DispatchQueue.main.async { completion(.failure(.nonExistent)) } 36 | return 37 | } 38 | 39 | let item = Media(from: object) 40 | 41 | DispatchQueue.main.async { 42 | completion(.success(item)) 43 | } 44 | } 45 | } 46 | } 47 | 48 | public extension MediaRealmCache { 49 | 50 | func fetch(ids: Set, completion: @escaping (Result<[Media], SwiftyPressError>) -> Void) { 51 | DispatchQueue.database.async { 52 | let realm: Realm 53 | 54 | do { 55 | realm = try Realm() 56 | } catch { 57 | DispatchQueue.main.async { completion(.failure(.databaseFailure(error))) } 58 | return 59 | } 60 | 61 | let items: [Media] = realm.objects(MediaRealmObject.self, forPrimaryKeys: ids) 62 | .map { Media(from: $0) } 63 | 64 | DispatchQueue.main.async { 65 | completion(.success(items)) 66 | } 67 | } 68 | } 69 | } 70 | 71 | public extension MediaRealmCache { 72 | 73 | func createOrUpdate(_ request: Media, completion: @escaping (Result) -> Void) { 74 | DispatchQueue.database.async { 75 | let realm: Realm 76 | 77 | do { 78 | realm = try Realm() 79 | } catch { 80 | DispatchQueue.main.async { completion(.failure(.databaseFailure(error))) } 81 | return 82 | } 83 | 84 | do { 85 | try realm.write { 86 | realm.add(MediaRealmObject(from: request), update: .modified) 87 | } 88 | } catch { 89 | DispatchQueue.main.async { completion(.failure(.databaseFailure(error))) } 90 | return 91 | } 92 | 93 | // Get refreshed object to return 94 | guard let object = realm.object(ofType: MediaRealmObject.self, forPrimaryKey: request.id) else { 95 | DispatchQueue.main.async { completion(.failure(.nonExistent)) } 96 | return 97 | } 98 | 99 | let item = Media(from: object) 100 | 101 | DispatchQueue.main.async { 102 | completion(.success(item)) 103 | } 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Views/UIKit/Extensions/UIImageView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIImageView.swift 3 | // SwiftyPress 4 | // 5 | // Created by Basem Emara on 2018-10-22. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | #if os(iOS) 10 | import UIKit 11 | import Nuke 12 | 13 | public extension UIImageView { 14 | 15 | /// Returns an image view initialized with the specified image. 16 | /// 17 | /// - Parameters: 18 | /// - named: The name of the image. 19 | /// - bundle: The bundle containing the image file or asset catalog. Specify nil to search the app's main bundle. 20 | convenience init(imageNamed name: UIImage.ImageName, inBundle bundle: Bundle? = nil) { 21 | self.init(image: UIImage(named: name, inBundle: bundle)) 22 | } 23 | } 24 | 25 | public extension UIImageView { 26 | 27 | /// Set an image asynchrously with a URL and placeholder using caching. 28 | /// 29 | /// - Parameters: 30 | /// - url: The URL of the image. 31 | /// - placeholder: The placeholder image when retrieving the image at the URL. 32 | func setImage( 33 | from url: String?, 34 | placeholder: String? = "placeholder", 35 | referenceSize: CGSize? = nil, 36 | contentMode: ResizingContentMode? = nil 37 | ) { 38 | let placeholder = placeholder != nil ? UIImage(named: placeholder ?? "") : nil 39 | setImage(from: url, placeholder: placeholder, referenceSize: referenceSize, contentMode: contentMode) 40 | } 41 | 42 | /// Set an image asynchrously with a URL and placeholder using caching. 43 | /// 44 | /// - Parameters: 45 | /// - url: The URL of the image. 46 | /// - placeholder: The placeholder image when retrieving the image at the URL. 47 | func setImage( 48 | from url: String?, 49 | placeholder: UIImage?, 50 | referenceSize: CGSize? = nil, 51 | contentMode: ResizingContentMode? = nil 52 | ) { 53 | guard let url = url, !url.isEmpty, let urlResource = URL(string: url) else { 54 | image = placeholder 55 | return 56 | } 57 | 58 | // Build options if applicable 59 | let options = ImageLoadingOptions( 60 | placeholder: placeholder, 61 | transition: .fadeIn(duration: 0.33), 62 | contentModes: { 63 | switch contentMode { 64 | case .aspectFit: 65 | return .init(success: .scaleAspectFit, failure: .scaleAspectFit, placeholder: .scaleAspectFill) 66 | case .aspectFill: 67 | return .init(success: .scaleAspectFill, failure: .scaleAspectFit, placeholder: .scaleAspectFill) 68 | default: 69 | return nil 70 | } 71 | }() 72 | ) 73 | 74 | var processors = [ImageProcessing]() 75 | 76 | if let referenceSize = referenceSize { 77 | processors.append(ImageProcessors.Resize(size: referenceSize)) 78 | } 79 | 80 | let request = ImageRequest( 81 | url: urlResource, 82 | processors: processors 83 | ) 84 | 85 | Nuke.loadImage(with: request, options: options, into: self) 86 | } 87 | 88 | /// Specify how a size adjusts itself to fit a target size. 89 | /// 90 | /// - none: Not scale the content. 91 | /// - aspectFit: Scale the content to fit the size of the view by maintaining the aspect ratio. 92 | /// - aspectFill: Scale the content to fill the size of the view 93 | enum ResizingContentMode { 94 | case none 95 | case aspectFit 96 | case aspectFill 97 | } 98 | } 99 | #endif 100 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Repositories/Post/Services/PostNetworkService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PostsNetworkService.swift 3 | // SwiftyPress 4 | // 5 | // Created by Basem Emara on 2018-10-10. 6 | // Copyright © 2018 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import ZamzamCore 11 | 12 | public struct PostNetworkService: PostService { 13 | private let networkRepository: NetworkRepository 14 | private let jsonDecoder: JSONDecoder 15 | private let constants: Constants 16 | private let log: LogRepository 17 | 18 | public init( 19 | networkRepository: NetworkRepository, 20 | jsonDecoder: JSONDecoder, 21 | constants: Constants, 22 | log: LogRepository 23 | ) { 24 | self.networkRepository = networkRepository 25 | self.jsonDecoder = jsonDecoder 26 | self.constants = constants 27 | self.log = log 28 | } 29 | } 30 | 31 | public extension PostNetworkService { 32 | 33 | func fetch(id: Int, with request: PostAPI.ItemRequest, completion: @escaping (Result) -> Void) { 34 | let urlRequest: URLRequest = .readPost(id: id, with: request, constants: constants) 35 | 36 | networkRepository.send(with: urlRequest) { 37 | // Handle errors 38 | guard case .success = $0 else { 39 | // Handle no existing data 40 | if $0.error?.statusCode == 404 { 41 | completion(.failure(.nonExistent)) 42 | return 43 | } 44 | 45 | self.log.error("An error occured while fetching the post: \(String(describing: $0.error)).") 46 | completion(.failure(SwiftyPressError(from: $0.error ?? .init(request: urlRequest)))) 47 | return 48 | } 49 | 50 | // Ensure available 51 | guard case let .success(item) = $0, let data = item.data else { 52 | completion(.failure(.nonExistent)) 53 | return 54 | } 55 | 56 | DispatchQueue.transform.async { 57 | do { 58 | // Parse response data 59 | let payload = try self.jsonDecoder.decode(ExtendedPost.self, from: data) 60 | 61 | DispatchQueue.main.async { 62 | completion(.success(payload)) 63 | } 64 | } catch { 65 | self.log.error("An error occured while parsing the post: \(error).") 66 | DispatchQueue.main.async { completion(.failure(.parseFailure(error))) } 67 | return 68 | } 69 | } 70 | } 71 | } 72 | } 73 | 74 | // MARK: - Requests 75 | 76 | private extension URLRequest { 77 | 78 | static func readPost(id: Int, with request: PostAPI.ItemRequest, constants: Constants) -> URLRequest { 79 | URLRequest( 80 | url: constants.baseURL 81 | .appendingPathComponent(constants.baseREST) 82 | .appendingPathComponent("post/\(id)"), 83 | method: .get, 84 | parameters: { 85 | var params: [String: Any] = [:] 86 | 87 | if !request.taxonomies.isEmpty { 88 | params["taxonomies"] = request.taxonomies 89 | .joined(separator: ",") 90 | } 91 | 92 | if !request.postMetaKeys.isEmpty { 93 | params["meta_keys"] = request.postMetaKeys 94 | .joined(separator: ",") 95 | } 96 | 97 | return params 98 | }() 99 | ) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /Tests/SwiftyPressModelTests/TestUtilities.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftyPress_ModelTests.swift 3 | // SwiftyPress ModelTests 4 | // 5 | // Created by Basem Emara on 2019-05-11. 6 | // 7 | 8 | #if !os(watchOS) 9 | import XCTest 10 | import ZamzamCore 11 | 12 | extension DateFormatter { 13 | static let iso8601 = DateFormatter(iso8601Format: "yyyy-MM-dd'T'HH:mm:ss") 14 | } 15 | 16 | extension JSONDecoder { 17 | 18 | /// Decodes the given type from the given JSON representation of the current file. 19 | func decode(_ type: T.Type, fromJSON file: String, suffix: String? = nil) throws -> T where T : Decodable { 20 | let fileName = URL(fileURLWithPath: file) 21 | .appendingToFileName(suffix ?? "") 22 | .deletingPathExtension() 23 | .lastPathComponent 24 | 25 | guard let url = Bundle.module.url(forResource: fileName, withExtension: "json"), 26 | let data = try? Data(contentsOf: url) 27 | else { 28 | throw NSError(domain: "SwiftyPressModelTests.JSONDecoder", code: NSFileReadUnknownError) 29 | } 30 | 31 | return try decode(type, from: data) 32 | } 33 | } 34 | 35 | extension XCTestCase { 36 | 37 | /// Asserts that all values are equal. 38 | /// 39 | /// - Parameters: 40 | /// - values: A list of values of type T, where T is Equatable. 41 | /// - message: An optional description of the failure. 42 | /// - file: The file in which failure occurred. Defaults to the file name of the test case in which this function was called. 43 | /// - line: The line number on which failure occurred. Defaults to the line number on which this function was called. 44 | func XCTAssertAllEqual( 45 | _ values: T?..., 46 | message: @autoclosure () -> String = "", 47 | file: StaticString = #filePath, 48 | line: UInt = #line 49 | ) { 50 | _ = values.reduce(values.first) { current, next in 51 | XCTAssertEqual(current, next, message(), file: file, line: line) 52 | return next 53 | } 54 | } 55 | 56 | /// Asserts that two values are equal and not nil. 57 | /// 58 | /// - Parameters: 59 | /// - expression1: An expression of type T, where T is Equatable. 60 | /// - values: An expression of type T, where T is Equatable. 61 | /// - expression2: An optional description of the failure. 62 | /// - file: The file in which failure occurred. Defaults to the file name of the test case in which this function was called. 63 | /// - line: The line number on which failure occurred. Defaults to the line number on which this function was called. 64 | func XCTAssertEqualAndNotNil( 65 | _ expression1: @autoclosure () -> T?, 66 | _ expression2: @autoclosure () -> T?, 67 | message: @autoclosure () -> String = "", 68 | file: StaticString = #filePath, 69 | line: UInt = #line 70 | ) { 71 | XCTAssertNotNil(expression1(), message(), file: file, line: line) 72 | XCTAssertNotNil(expression2(), message(), file: file, line: line) 73 | XCTAssertEqual(expression1(), expression2(), message(), file: file, line: line) 74 | } 75 | } 76 | 77 | // MARK: - Utility Testing 78 | 79 | final class UtilitiesTests: XCTestCase { 80 | 81 | func testAssertAllEqual() throws { 82 | XCTAssertAllEqual(1, 1, 1, 1, 1, 1, 1) 83 | XCTAssertAllEqual("a", "a", "a", "a", "a") 84 | 85 | // Tested below but must exclude from live test runs 86 | // XCTAssertAllEqual(1, 1, 1, 2, 1, 1, 1) 87 | } 88 | 89 | func testAssertEqualAndNotNil() throws { 90 | XCTAssertEqualAndNotNil(1, 1) 91 | 92 | // Tested below but must exclude from live test runs 93 | // let value: String? = nil 94 | // XCTAssertEqualAndNotNil(value, value) 95 | } 96 | } 97 | #endif 98 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Repositories/Post/Models/PostRealmObject.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PostRealmObject.swift 3 | // SwiftyPress 4 | // 5 | // Created by Basem Emara on 2018-10-17. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | import Foundation.NSDate 10 | import RealmSwift 11 | 12 | @objcMembers 13 | class PostRealmObject: Object, PostType { 14 | dynamic var id: Int = 0 15 | dynamic var slug: String = "" 16 | dynamic var type: String = "" 17 | dynamic var title: String = "" 18 | dynamic var content: String = "" 19 | dynamic var excerpt: String = "" 20 | dynamic var link: String = "" 21 | dynamic var commentCount: Int = 0 22 | dynamic var authorID: Int = 0 23 | dynamic var createdAt: Date = .distantPast 24 | dynamic var modifiedAt: Date = .distantPast 25 | 26 | let mediaIDRaw = RealmOptional() 27 | var termsRaw = List() 28 | var metaRaw = List() 29 | 30 | override static func primaryKey() -> String? { 31 | "id" 32 | } 33 | 34 | override static func indexedProperties() -> [String] { 35 | ["slug"] 36 | } 37 | } 38 | 39 | // MARK: - Workarounds 40 | 41 | extension PostRealmObject { 42 | 43 | var mediaID: Int? { 44 | get { mediaIDRaw.value } 45 | set { mediaIDRaw.value = newValue } 46 | } 47 | 48 | var terms: [Int] { 49 | get { termsRaw.map { $0.id } } 50 | set { 51 | termsRaw = List().apply { 52 | $0.append(objectsIn: newValue.map { id in 53 | TermIDRealmObject().apply { $0.id = id } 54 | }) 55 | } 56 | } 57 | } 58 | 59 | var meta: [String: String] { 60 | get { 61 | Dictionary(uniqueKeysWithValues: metaRaw 62 | .map { ($0.key, $0.value) }) 63 | } 64 | 65 | set { 66 | metaRaw = List().apply { 67 | $0.append(objectsIn: newValue.map { item in 68 | MetaRealmObject().apply { 69 | $0.key = item.key 70 | $0.value = item.value 71 | } 72 | }) 73 | } 74 | } 75 | } 76 | } 77 | 78 | /// Dummy type since no support for queries of array of primitives 79 | @objcMembers 80 | class TermIDRealmObject: Object { 81 | // https://github.com/realm/realm-object-store/issues/513 82 | dynamic var id: Int = 0 83 | override static func primaryKey() -> String? { "id" } 84 | } 85 | 86 | @objcMembers 87 | class MetaRealmObject: Object { 88 | dynamic var key: String = "" 89 | dynamic var value: String = "" 90 | } 91 | 92 | // MARK: - Conversions 93 | 94 | extension PostRealmObject { 95 | 96 | /// For converting to one type to another. 97 | /// 98 | /// - Parameter object: An instance of post type. 99 | convenience init(from object: PostType) { 100 | self.init() 101 | self.id = object.id 102 | self.slug = object.slug 103 | self.type = object.type 104 | self.title = object.title 105 | self.content = object.content 106 | self.excerpt = object.excerpt 107 | self.link = object.link 108 | self.commentCount = object.commentCount 109 | self.authorID = object.authorID 110 | self.mediaID = object.mediaID 111 | self.terms = object.terms 112 | self.meta = object.meta 113 | self.createdAt = object.createdAt 114 | self.modifiedAt = object.modifiedAt 115 | } 116 | 117 | /// For converting to one type to another. 118 | /// 119 | /// - Parameter object: An instance of post type. 120 | convenience init?(from object: PostType?) { 121 | guard let object = object else { return nil } 122 | self.init(from: object) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Repositories/Taxonomy/Services/TaxonomyFileCache.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TaxonomyFileCache.swift 3 | // SwiftyPress 4 | // 5 | // Created by Basem Emara on 2018-06-04. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | public struct TaxonomyFileCache: TaxonomyCache { 10 | private let seedService: DataSeed 11 | 12 | init(seedService: DataSeed) { 13 | self.seedService = seedService 14 | } 15 | } 16 | 17 | public extension TaxonomyFileCache { 18 | 19 | func fetch(id: Int, completion: @escaping (Result) -> Void) { 20 | fetch { 21 | // Handle errors 22 | guard case .success = $0 else { 23 | completion(.failure($0.error ?? .unknownReason(nil))) 24 | return 25 | } 26 | 27 | // Find match 28 | guard case let .success(item) = $0, 29 | let model = item.first(where: { $0.id == id }) else { 30 | completion(.failure(.nonExistent)) 31 | return 32 | } 33 | 34 | completion(.success(model)) 35 | } 36 | } 37 | 38 | func fetch(slug: String, completion: @escaping (Result) -> Void) { 39 | fetch { 40 | // Handle errors 41 | guard case .success = $0 else { 42 | completion(.failure($0.error ?? .unknownReason(nil))) 43 | return 44 | } 45 | 46 | // Find match 47 | guard case let .success(item) = $0, 48 | let model = item.first(where: { $0.slug == slug }) else { 49 | completion(.failure(.nonExistent)) 50 | return 51 | } 52 | 53 | completion(.success(model)) 54 | } 55 | } 56 | } 57 | 58 | public extension TaxonomyFileCache { 59 | 60 | func fetch(completion: @escaping (Result<[Term], SwiftyPressError>) -> Void) { 61 | seedService.fetch { 62 | guard case let .success(item) = $0 else { 63 | completion(.failure($0.error ?? .unknownReason(nil))) 64 | return 65 | } 66 | 67 | completion(.success(item.terms)) 68 | } 69 | } 70 | } 71 | 72 | public extension TaxonomyFileCache { 73 | 74 | func fetch(ids: Set, completion: @escaping (Result<[Term], SwiftyPressError>) -> Void) { 75 | fetch { 76 | guard case let .success(items) = $0 else { 77 | completion($0) 78 | return 79 | } 80 | 81 | let model = ids.reduce(into: [Term]()) { result, next in 82 | guard let element = items.first(where: { $0.id == next }) else { return } 83 | result.append(element) 84 | } 85 | 86 | completion(.success(model)) 87 | } 88 | } 89 | 90 | func fetch(by taxonomy: Taxonomy, completion: @escaping (Result<[Term], SwiftyPressError>) -> Void) { 91 | fetch { 92 | guard case let .success(items) = $0 else { 93 | completion($0) 94 | return 95 | } 96 | 97 | completion(.success(items.filter { $0.taxonomy == taxonomy })) 98 | } 99 | } 100 | 101 | func fetch(by taxonomies: [Taxonomy], completion: @escaping (Result<[Term], SwiftyPressError>) -> Void) { 102 | fetch { 103 | guard case let .success(items) = $0 else { 104 | completion($0) 105 | return 106 | } 107 | 108 | completion(.success(items.filter { taxonomies.contains($0.taxonomy) })) 109 | } 110 | } 111 | } 112 | 113 | public extension TaxonomyFileCache { 114 | 115 | func getID(bySlug slug: String) -> Int? { 116 | fatalError("Not implemented") 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Views/UIKit/Controls/DataViews/PostsDataView/Cells/PickedPostCollectionViewCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PickedPostCollectionViewCell.swift 3 | // Basem Emara 4 | // 5 | // Created by Basem Emara on 2018-06-25. 6 | // Copyright © 2018 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import ZamzamCore 11 | 12 | public final class PickedPostCollectionViewCell: UICollectionViewCell { 13 | 14 | private let titleLabel = ThemedHeadline().apply { 15 | $0.font = .preferredFont(forTextStyle: .headline) 16 | $0.numberOfLines = 2 17 | } 18 | 19 | private let summaryLabel = ThemedSubhead().apply { 20 | $0.font = .preferredFont(forTextStyle: .subheadline) 21 | $0.numberOfLines = 3 22 | } 23 | 24 | private let featuredImage = ThemedImageView(imageNamed: .placeholder).apply { 25 | $0.contentMode = .scaleAspectFill 26 | $0.clipsToBounds = true 27 | } 28 | 29 | private lazy var favoriteButton = ThemedImageButton().apply { 30 | $0.setImage(UIImage(named: .favoriteEmpty), for: .normal) 31 | $0.setImage(UIImage(named: .favoriteFilled), for: .selected) 32 | $0.imageView?.contentMode = .scaleAspectFit 33 | $0.addTarget(self, action: #selector(didTapFavoriteButton), for: .touchUpInside) // Must be in lazy init 34 | } 35 | 36 | private var model: PostsDataViewModel? 37 | private weak var delegate: PostsDataViewDelegate? 38 | 39 | public override init(frame: CGRect) { 40 | super.init(frame: frame) 41 | prepare() 42 | } 43 | 44 | @available(*, unavailable) 45 | public required init?(coder: NSCoder) { nil } 46 | } 47 | 48 | // MARK: - Setup 49 | 50 | private extension PickedPostCollectionViewCell { 51 | 52 | func prepare() { 53 | let favoriteView = UIView().apply { 54 | $0.backgroundColor = .clear 55 | $0.addSubview(favoriteButton) 56 | } 57 | 58 | let stackView = UIStackView(arrangedSubviews: [ 59 | featuredImage, 60 | UIStackView(arrangedSubviews: [ 61 | titleLabel.apply { 62 | $0.setContentHuggingPriority(.defaultHigh, for: .vertical) 63 | }, 64 | summaryLabel, 65 | favoriteView 66 | ]).apply { 67 | $0.axis = .vertical 68 | $0.spacing = 5 69 | } 70 | ]).apply { 71 | $0.axis = .horizontal 72 | $0.spacing = 8 73 | } 74 | 75 | let view = ThemedView().apply { 76 | $0.addSubview(stackView) 77 | } 78 | 79 | addSubview(view) 80 | 81 | view.edges(to: self) 82 | stackView.edges(to: view, insets: UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 16)) 83 | 84 | featuredImage.aspectRatioSize() 85 | 86 | favoriteView.heightAnchor.constraint(equalTo: favoriteButton.heightAnchor).isActive = true 87 | favoriteButton.heightAnchor.constraint(equalToConstant: 24).isActive = true 88 | favoriteButton.center() 89 | } 90 | } 91 | 92 | // MARK: - Interactions 93 | 94 | private extension PickedPostCollectionViewCell { 95 | 96 | @objc func didTapFavoriteButton() { 97 | favoriteButton.isSelected.toggle() 98 | guard let model = model else { return } 99 | delegate?.postsDataView(toggleFavorite: model) 100 | } 101 | } 102 | 103 | // MARK: - Delegates 104 | 105 | extension PickedPostCollectionViewCell: PostsDataViewCell { 106 | 107 | public func load(_ model: PostsDataViewModel) { 108 | self.model = model 109 | 110 | titleLabel.text = model.title 111 | summaryLabel.text = model.summary 112 | featuredImage.setImage(from: model.imageURL) 113 | favoriteButton.isSelected = model.favorite 114 | } 115 | 116 | public func load(_ model: PostsDataViewModel, delegate: PostsDataViewDelegate?) { 117 | self.delegate = delegate 118 | load(model) 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Data/Services/DataNetworkService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DataNetworkService.swift 3 | // SwiftyPress 4 | // 5 | // Created by Basem Emara on 2018-10-09. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import ZamzamCore 11 | 12 | public struct DataNetworkService: DataService { 13 | private let networkRepository: NetworkRepository 14 | private let jsonDecoder: JSONDecoder 15 | private let constants: Constants 16 | private let log: LogRepository 17 | 18 | public init( 19 | networkRepository: NetworkRepository, 20 | jsonDecoder: JSONDecoder, 21 | constants: Constants, 22 | log: LogRepository 23 | ) { 24 | self.networkRepository = networkRepository 25 | self.jsonDecoder = jsonDecoder 26 | self.constants = constants 27 | self.log = log 28 | } 29 | } 30 | 31 | public extension DataNetworkService { 32 | 33 | func fetchModified( 34 | after date: Date?, 35 | with request: DataAPI.ModifiedRequest, 36 | completion: @escaping (Result) -> Void 37 | ) { 38 | let urlRequest: URLRequest = .modified(after: date, with: request, constants: constants) 39 | 40 | networkRepository.send(with: urlRequest) { 41 | guard case let .success(item) = $0 else { 42 | // Handle no modified data and return success 43 | if $0.error?.statusCode == 304 { 44 | completion(.success(SeedPayload())) 45 | return 46 | } 47 | 48 | self.log.error("An error occured while fetching the modified payload: \(String(describing: $0.error)).") 49 | completion(.failure(SwiftyPressError(from: $0.error ?? .init(request: urlRequest)))) 50 | return 51 | } 52 | 53 | guard let data = item.data else { 54 | completion(.failure(.nonExistent)) 55 | return 56 | } 57 | 58 | DispatchQueue.transform.async { 59 | do { 60 | // Parse response data 61 | let payload = try self.jsonDecoder.decode(SeedPayload.self, from: data) 62 | DispatchQueue.main.async { completion(.success(payload)) } 63 | } catch { 64 | self.log.error("An error occured while parsing the modified payload: \(error).") 65 | DispatchQueue.main.async { completion(.failure(.parseFailure(error))) } 66 | return 67 | } 68 | } 69 | } 70 | } 71 | } 72 | 73 | // MARK: - Requests 74 | 75 | private extension URLRequest { 76 | 77 | static func modified(after: Date?, with request: DataAPI.ModifiedRequest, constants: Constants) -> URLRequest { 78 | URLRequest( 79 | url: constants.baseURL 80 | .appendingPathComponent(constants.baseREST) 81 | .appendingPathComponent("modified"), 82 | method: .get, 83 | parameters: { 84 | var params: [String: Any] = [:] 85 | 86 | if let timestamp = after?.timeIntervalSince1970 { 87 | params["after"] = Int(timestamp) 88 | } 89 | 90 | if !request.taxonomies.isEmpty { 91 | params["taxonomies"] = request.taxonomies 92 | .joined(separator: ",") 93 | } 94 | 95 | if !request.postMetaKeys.isEmpty { 96 | params["meta_keys"] = request.postMetaKeys 97 | .joined(separator: ",") 98 | } 99 | 100 | if let limit = request.limit { 101 | params["limit"] = limit 102 | } 103 | 104 | return params 105 | }(), 106 | timeoutInterval: 30 107 | ) 108 | } 109 | } 110 | 111 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Views/UIKit/Controls/Layouts/MultiRowLayout.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MultiRowLayout.swift 3 | // Snap page and center collection view with multi-row cell 4 | // https://stackoverflow.com/a/32167976/235334 5 | // 6 | // Created by Basem Emara on 2018-10-03. 7 | // Copyright © 2019 Zamzam Inc. All rights reserved. 8 | // 9 | 10 | #if os(iOS) 11 | import UIKit 12 | 13 | open class MultiRowLayout: UICollectionViewFlowLayout { 14 | private var rowsCount: CGFloat = 0 15 | 16 | public convenience init(rowsCount: CGFloat, spacing: CGFloat? = nil, inset: CGFloat? = nil) { 17 | self.init() 18 | 19 | self.scrollDirection = .horizontal 20 | self.minimumInteritemSpacing = 0 21 | self.rowsCount = rowsCount 22 | 23 | if let spacing = spacing { 24 | self.minimumLineSpacing = spacing 25 | } 26 | 27 | if let inset = inset { 28 | self.sectionInset = UIEdgeInsets(top: 0, left: inset, bottom: 0, right: inset) 29 | } 30 | } 31 | 32 | open override func prepare() { 33 | super.prepare() 34 | 35 | guard let collectionView = collectionView else { return } 36 | self.itemSize = calculateItemSize(from: collectionView.bounds.size) 37 | } 38 | 39 | open override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { 40 | guard let collectionView = collectionView, 41 | !newBounds.size.equalTo(collectionView.bounds.size) else { 42 | return false 43 | } 44 | 45 | itemSize = calculateItemSize(from: collectionView.bounds.size) 46 | return true 47 | } 48 | } 49 | 50 | private extension MultiRowLayout { 51 | 52 | func calculateItemSize(from containerSize: CGSize) -> CGSize { 53 | CGSize( 54 | width: containerSize.width - minimumLineSpacing * 2 - sectionInset.left, 55 | height: containerSize.height / rowsCount 56 | ) 57 | } 58 | } 59 | 60 | extension MultiRowLayout: ScrollableFlowLayout { 61 | 62 | open func willBeginDragging() {} 63 | 64 | open func willEndDragging(withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { 65 | guard let collectionView = collectionView else { return } 66 | let bounds = collectionView.bounds 67 | let xTarget = targetContentOffset.pointee.x 68 | 69 | // This is the max contentOffset.x to allow. With this as contentOffset.x, the right edge 70 | // of the last column of cells is at the right edge of the collection view's frame. 71 | let xMax = collectionView.contentSize.width - collectionView.bounds.width 72 | 73 | // Velocity is measured in points per millisecond. 74 | let snapToMostVisibleColumnVelocityThreshold: CGFloat = 0.3 75 | 76 | if abs(velocity.x) <= snapToMostVisibleColumnVelocityThreshold { 77 | let xCenter = collectionView.bounds.midX 78 | let poses = layoutAttributesForElements(in: bounds) ?? [] 79 | // Find the column whose center is closest to the collection view's visible rect's center. 80 | let x = poses.min { abs($0.center.x - xCenter) < abs($1.center.x - xCenter) }?.frame.origin.x ?? 0 81 | targetContentOffset.pointee.x = x - sectionInset.left 82 | } else if velocity.x > 0 { 83 | let poses = layoutAttributesForElements(in: CGRect(x: xTarget, y: 0, width: bounds.size.width, height: bounds.size.height)) ?? [] 84 | // Find the leftmost column beyond the current position. 85 | let xCurrent = collectionView.contentOffset.x 86 | let x = poses.filter { $0.frame.origin.x > xCurrent }.min { $0.center.x < $1.center.x }?.frame.origin.x ?? xMax 87 | targetContentOffset.pointee.x = min(x - sectionInset.left, xMax) 88 | } else { 89 | let poses = layoutAttributesForElements(in: CGRect(x: xTarget - bounds.size.width, y: 0, width: bounds.size.width, height: bounds.size.height)) ?? [] 90 | // Find the rightmost column. 91 | let x = poses.max { $0.center.x < $1.center.x }?.frame.origin.x ?? 0 92 | targetContentOffset.pointee.x = max(x - sectionInset.left, 0) 93 | } 94 | } 95 | } 96 | #endif 97 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Resources/en.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | /* 2 | Localizable.strings 3 | SwiftyPress 4 | 5 | Created by Basem Emara on 2018-09-07. 6 | Copyright © 2020 Zamzam Inc. All rights reserved. 7 | */ 8 | 9 | "duplicate.failure.error.message" = "The data you submitted already exists."; 10 | "non.existent.error.message" = "No data was found for your request."; 11 | "unauthorized.error.title" = "Unauthorized Access"; 12 | "unauthorized.error.message" = "You must log in to view this content. 
Please log in or sign up."; 13 | "no.internet.error.message" = "No internet connection is available."; 14 | "server.timeout.error.message" = "The server could not be reached. Please try again shortly."; 15 | 16 | "parse.failure.error.message" = "The response from the server could not be read."; 17 | "database.failure.error.message" = "An unexpected database error has occurred."; 18 | "cache.failure.error.message" = "An unexpected error has occurred with the local database, but may have succeeded in the cloud."; 19 | "server.failure.error.message" = "An unexpected server error has occurred."; 20 | "bad.request.error.message" = "The server did not recognize your request."; 21 | "unknown.reason.error.message" = "There was an error with your request."; 22 | 23 | "generic.incomplete.form.error.message" = "The form is incomplete. Please fill out the form and try again."; 24 | 25 | "latest.posts.error.title" = "Latest Posts Error"; 26 | "popular.posts.error.title" = "Popular Posts Error"; 27 | "top.pick.posts.error.title" = "Top Pick Posts Error"; 28 | "posts.by.terms.error.title" = "Posts by Terms Error"; 29 | "terms.error.title" = "Terms Error"; 30 | "blog.post.error.title" = "Blog Post Error"; 31 | "search.error.title" = "Search Error"; 32 | "could.not.send.email" = "Could Not Send Email"; 33 | "could.not.send.email.message" = "Your device could not send e-mail."; 34 | 35 | "browser.not.available.error.title" = "Browser Not Available"; 36 | "comments.not.available.error.title" = "Comments Not Available"; 37 | "not.connected.to.internet.error.message" = "You are not connected to the Internet."; 38 | "no.post.in.history.error.message" = "No previous post in history"; 39 | "disclaimer.not.available.error.title" = "Disclaimer Not Available"; 40 | "disclaimer.not.available.error.message" = "No disclaimer information is available."; 41 | 42 | "category.section" = "Categories"; 43 | "tag.section" = "Tags"; 44 | 45 | "see.all.button" = "See All"; 46 | 47 | "favorites.title" = "Favorites"; 48 | "empty.favorites.message" = "Looks like you don't have any favorites yet."; 49 | 50 | "empty.search.message" = "Looks like you don't have any search results."; 51 | 52 | "list.terms.title" = "Categories & Tags"; 53 | "favorite.title" = "Favorite"; 54 | "unfavor.title" = "Unfavor"; 55 | "unfavorite.title" = "Unfavorite"; 56 | "comments.title" = "Comments"; 57 | "disclaimer.button.title" = "Disclaimer"; 58 | "privacy.button.title" = "Privacy"; 59 | "contact.button.title" = "Contact"; 60 | 61 | "latest.posts.title" = "Latest Posts"; 62 | "popular.posts.title" = "Popular Posts"; 63 | "top.picks.title" = "Top Picks"; 64 | "posts.by.terms.title" = "Posts by Terms"; 65 | 66 | "search.placeholder" = "Search Blog Posts"; 67 | "search.all.scope" = "All"; 68 | "search.title.scope" = "Title"; 69 | "search.content.scope" = "Content"; 70 | "search.keywords.scope" = "Keywords"; 71 | 72 | "email.feedback.subject" = "Feedback: %@"; 73 | "share.app.message" = "%@ is awesome! Check out the app!"; 74 | 75 | "tab.home.title" = "Home"; 76 | "tab.blog.title" = "Blog"; 77 | "tab.favorites.title" = "Favorites"; 78 | "tab.search.title" = "Search"; 79 | "tab.more.title" = "More"; 80 | 81 | "github.social.title" = "GitHub"; 82 | "linkedIn.social.title" = "LinkedIn"; 83 | "twitter.social.title" = "Twitter"; 84 | "pinterest.social.title" = "Pinterest"; 85 | "instagram.social.title" = "Instagram"; 86 | "email.social.title" = "Email"; 87 | 88 | "more.menu.subscribe.title" = "Subscribe"; 89 | "more.menu.feedback.title" = "Send us an email"; 90 | "more.menu.work.title" = "Work with us"; 91 | "more.menu.rate.title" = "Rate our app"; 92 | "more.menu.share.title" = "Share our app"; 93 | "more.social.section.title" = "Social"; 94 | "more.other.section.title" = "Other"; 95 | "more.menu.developed.by.title" = "Developed by Zamzam"; 96 | 97 | "settings.menu.theme.title" = "Use iOS theme"; 98 | "settings.menu.notifications.title" = "Get notifications"; 99 | "settings.menu.phone.settings.title" = "iOS Settings"; 100 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Resources/ar.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | /* 2 | Localizable.strings 3 | SwiftyPress 4 | 5 | Created by Basem Emara on 2018-09-07. 6 | Copyright © 2020 Zamzam Inc. All rights reserved. 7 | */ 8 | 9 | "duplicate.failure.error.message" = "The data you submitted already exists."; 10 | "non.existent.error.message" = "No data was found for your request."; 11 | "unauthorized.error.title" = "Unauthorized Access"; 12 | "unauthorized.error.message" = "You must log in to view this content.
Please log in or sign up."; 13 | "no.internet.error.message" = "No internet connection is available."; 14 | "server.timeout.error.message" = "The server could not be reached. Please try again shortly."; 15 | 16 | "parse.failure.error.message" = "The response from the server could not be read."; 17 | "database.failure.error.message" = "An unexpected database error has occurred."; 18 | "cache.failure.error.message" = "An unexpected error has occurred with the local database, but may have succeeded in the cloud."; 19 | "server.failure.error.message" = "An unexpected server error has occurred."; 20 | "bad.request.error.message" = "The server did not recognize your request."; 21 | "unknown.reason.error.message" = "There was an error with your request."; 22 | 23 | "generic.incomplete.form.error.message" = "The form is incomplete. Please fill out the form and try again."; 24 | 25 | "latest.posts.error.title" = "خطأ في الحصول على أحدث المقالات"; 26 | "popular.posts.error.title" = "خطأ في الحصول على المقالات المشورة"; 27 | "top.pick.posts.error.title" = "خطأ في الحصول على المقالات المختارة"; 28 | "posts.by.terms.error.title" = "خطأ في الحصول على التصنيفات"; 29 | "terms.error.title" = "خطأ اثناء الحصول على التصنيف"; 30 | "blog.post.error.title" = "حدث خطأ اثناء عرض المقال"; 31 | "search.error.title" = "حدث خطأ اثناء البحث"; 32 | "could.not.send.email" = "لم نتمكن من إرسال البريد"; 33 | "could.not.send.email.message" = "لم يتم إعداد البريد على جهازك"; 34 | 35 | "browser.not.available.error.title" = "المتصفح غير متاح"; 36 | "comments.not.available.error.title" = "التعليقات غير متاحة"; 37 | "not.connected.to.internet.error.message" = "انت غير متصل بالانترنت"; 38 | "no.post.in.history.error.message" = "لا يوجد مقالات سابقة"; 39 | "disclaimer.not.available.error.title" = "الصفحة غير متاحة"; 40 | "disclaimer.not.available.error.message" = "المعلومات غير متاحة"; 41 | 42 | "tag.section" = "الوسوم"; 43 | "category.section" = "التصنيفات"; 44 | 45 | "see.all.button" = "See All"; 46 | 47 | "favorites.title" = "Favorites"; 48 | "empty.favorites.message" = "Looks like you don't have any favorites yet."; 49 | 50 | "empty.search.message" = "Looks like you don't have any search results."; 51 | 52 | "list.terms.title" = "Categories & Tags"; 53 | "favorite.title" = "المفضلة"; 54 | "unfavor.title" = "حذف المفضل"; 55 | "unfavorite.title" = "حذف المفضل"; 56 | "comments.title" = "التعليقات"; 57 | "disclaimer.button.title" = "Disclaimer"; 58 | "privacy.button.title" = "Privacy"; 59 | "contact.button.title" = "Contact"; 60 | 61 | "latest.posts.title" = "احدث المقالات"; 62 | "popular.posts.title" = "مقالات مشهورة"; 63 | "top.picks.title" = "مقالات مختارة"; 64 | "posts.by.terms.title" = "مقالات بالتصنيف"; 65 | 66 | "search.placeholder" = "أبحث في المقالات"; 67 | "search.all.scope" = "الكل"; 68 | "search.title.scope" = "العنوان"; 69 | "search.content.scope" = "المحتوى"; 70 | "search.keywords.scope" = "كلمات مفتاحية"; 71 | 72 | "email.feedback.subject" = "من تطبيق %@"; 73 | "share.app.message" = "%@ is awesome! Check out the app!"; 74 | 75 | "tab.home.title" = "الرئيسية"; 76 | "tab.blog.title" = "مدونة"; 77 | "tab.favorites.title" = "المفضلة"; 78 | "tab.search.title" = "البحث"; 79 | "tab.more.title" = "المزيد"; 80 | 81 | "github.social.title" = "GitHub"; 82 | "linkedIn.social.title" = "LinkedIn"; 83 | "twitter.social.title" = "Twitter"; 84 | "pinterest.social.title" = "Pinterest"; 85 | "instagram.social.title" = "Instagram"; 86 | "email.social.title" = "Email"; 87 | 88 | "more.menu.subscribe.title" = "Subscribe"; 89 | "more.menu.feedback.title" = "Send us an email"; 90 | "more.menu.work.title" = "Work with us"; 91 | "more.menu.rate.title" = "Rate our app"; 92 | "more.menu.share.title" = "Share our app"; 93 | "more.social.section.title" = "Social"; 94 | "more.other.section.title" = "Other"; 95 | "more.menu.developed.by.title" = "Developed by Zamzam"; 96 | 97 | "settings.menu.theme.title" = "Use iOS theme"; 98 | "settings.menu.notifications.title" = "Get notifications"; 99 | "settings.menu.phone.settings.title" = "iOS Settings"; 100 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Views/UIKit/Controls/DataViews/PostsDataView/Cells/PopularPostCollectionViewCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PopularPostCollectionViewCell.swift 3 | // Basem Emara 4 | // 5 | // Created by Basem Emara on 2018-06-24. 6 | // Copyright © 2018 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import ZamzamCore 11 | 12 | public final class PopularPostCollectionViewCell: UICollectionViewCell { 13 | 14 | private let titleLabel = ThemedHeadline().apply { 15 | $0.font = .preferredFont(forTextStyle: .headline) 16 | $0.numberOfLines = 2 17 | } 18 | 19 | private let summaryLabel = ThemedSubhead().apply { 20 | $0.font = .preferredFont(forTextStyle: .subheadline) 21 | $0.numberOfLines = 1 22 | } 23 | 24 | private let featuredImage = ThemedImageView(imageNamed: .placeholder).apply { 25 | $0.contentMode = .scaleAspectFill 26 | $0.clipsToBounds = true 27 | } 28 | 29 | private lazy var favoriteButton = ThemedImageButton().apply { 30 | $0.setImage(UIImage(named: .favoriteEmpty), for: .normal) 31 | $0.setImage(UIImage(named: .favoriteFilled), for: .selected) 32 | $0.imageView?.contentMode = .scaleAspectFit 33 | $0.addTarget(self, action: #selector(didTapFavoriteButton), for: .touchUpInside) // Must be in lazy init 34 | } 35 | 36 | private var model: PostsDataViewModel? 37 | private weak var delegate: PostsDataViewDelegate? 38 | 39 | override init(frame: CGRect) { 40 | super.init(frame: frame) 41 | prepare() 42 | } 43 | 44 | @available(*, unavailable) 45 | required init?(coder: NSCoder) { nil } 46 | } 47 | 48 | // MARK: - Setup 49 | 50 | private extension PopularPostCollectionViewCell { 51 | 52 | func prepare() { 53 | let separator = ThemedSeparator() 54 | 55 | let favoriteView = UIView().apply { 56 | $0.backgroundColor = .clear 57 | $0.addSubview(favoriteButton) 58 | } 59 | 60 | let stackView = UIStackView(arrangedSubviews: [ 61 | featuredImage, 62 | UIStackView(arrangedSubviews: [ 63 | titleLabel.apply { 64 | $0.setContentHuggingPriority(.defaultHigh, for: .vertical) 65 | }, 66 | summaryLabel 67 | ]).apply { 68 | $0.axis = .vertical 69 | $0.spacing = 5 70 | }, 71 | favoriteView 72 | ]).apply { 73 | $0.axis = .horizontal 74 | $0.spacing = 16 75 | } 76 | 77 | let view = ThemedView().apply { 78 | $0.addSubview(stackView) 79 | } 80 | 81 | addSubview(view) 82 | addSubview(separator) 83 | 84 | view.edges(to: self) 85 | stackView.edges(to: view, insets: UIEdgeInsets(top: 10, left: 0, bottom: 10, right: 18)) 86 | 87 | featuredImage.aspectRatioSize() 88 | 89 | favoriteView.widthAnchor.constraint(equalTo: favoriteButton.widthAnchor).isActive = true 90 | favoriteButton.widthAnchor.constraint(equalToConstant: 24).isActive = true 91 | favoriteButton.center() 92 | 93 | separator.translatesAutoresizingMaskIntoConstraints = false 94 | separator.heightAnchor.constraint(equalToConstant: 1).isActive = true 95 | separator.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor, constant: 0).isActive = true 96 | separator.trailingAnchor.constraint(equalTo: trailingAnchor, constant: 0).isActive = true 97 | separator.bottomAnchor.constraint(equalTo: bottomAnchor, constant: 0).isActive = true 98 | } 99 | } 100 | 101 | // MARK: - Interactions 102 | 103 | private extension PopularPostCollectionViewCell { 104 | 105 | @objc func didTapFavoriteButton() { 106 | favoriteButton.isSelected.toggle() 107 | guard let model = model else { return } 108 | delegate?.postsDataView(toggleFavorite: model) 109 | } 110 | } 111 | 112 | // MARK: - Delegates 113 | 114 | extension PopularPostCollectionViewCell: PostsDataViewCell { 115 | 116 | public func load(_ model: PostsDataViewModel) { 117 | self.model = model 118 | 119 | titleLabel.text = model.title 120 | summaryLabel.text = model.summary 121 | featuredImage.setImage(from: model.imageURL) 122 | favoriteButton.isSelected = model.favorite 123 | } 124 | 125 | public func load(_ model: PostsDataViewModel, delegate: PostsDataViewDelegate?) { 126 | self.delegate = delegate 127 | load(model) 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/SwiftyPress.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 43 | 49 | 50 | 51 | 52 | 53 | 58 | 59 | 61 | 67 | 68 | 69 | 71 | 77 | 78 | 79 | 80 | 81 | 91 | 92 | 98 | 99 | 105 | 106 | 107 | 108 | 110 | 111 | 114 | 115 | 116 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Views/UIKit/Controls/DataViews/TermsDataView/TermsDataViewAdapter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TermsDataViewAdapter.swift 3 | // Basem Emara 4 | // 5 | // Created by Basem Emara on 2018-06-25. 6 | // Copyright © 2018 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | #if os(iOS) 10 | import UIKit 11 | import ZamzamCore 12 | import ZamzamUI 13 | 14 | open class TermsDataViewAdapter: NSObject { 15 | private weak var delegate: TermsDataViewDelegate? 16 | 17 | private let dataView: DataViewable 18 | private var groupedViewModels = [Taxonomy: [TermsDataViewModel]]() 19 | private var groupedSections = [Taxonomy]() 20 | 21 | public private(set) var viewModels: [TermsDataViewModel]? { 22 | didSet { 23 | guard let viewModels = viewModels else { 24 | groupedViewModels = [Taxonomy: [TermsDataViewModel]]() 25 | groupedSections = [Taxonomy]() 26 | return 27 | } 28 | 29 | groupedViewModels = Dictionary(grouping: viewModels, by: { $0.taxonomy }) 30 | groupedSections = Array(groupedViewModels.keys.sorted { $0.localized < $1.localized }) 31 | } 32 | } 33 | 34 | public init(for dataView: DataViewable, delegate: TermsDataViewDelegate? = nil) { 35 | self.dataView = dataView 36 | self.delegate = delegate 37 | 38 | super.init() 39 | 40 | // Set data view delegates 41 | if let tableView = dataView as? UITableView { 42 | tableView.delegate = self 43 | tableView.dataSource = self 44 | } else if let collectionView = dataView as? UICollectionView { 45 | collectionView.delegate = self 46 | collectionView.dataSource = self 47 | } 48 | } 49 | } 50 | 51 | extension TermsDataViewAdapter { 52 | 53 | open func reloadData(with viewModels: [TermsDataViewModel]) { 54 | self.viewModels = viewModels 55 | 56 | dataView.reloadData() 57 | delegate?.termsDataViewDidReloadData() 58 | } 59 | } 60 | 61 | // MARK: - UITableView delegates 62 | 63 | extension TermsDataViewAdapter: UITableViewDelegate { 64 | 65 | open func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 66 | tableView.deselectRow(at: indexPath, animated: true) //Handle cell highlight 67 | 68 | delegate?.termsDataView( 69 | didSelect: element(in: indexPath), 70 | at: indexPath, 71 | from: tableView 72 | ) 73 | } 74 | 75 | open func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { 76 | groupedSections.count > 1 ? groupedSections[section].localized : nil 77 | } 78 | } 79 | 80 | extension TermsDataViewAdapter: UITableViewDataSource { 81 | 82 | open func numberOfSections(in tableView: UITableView) -> Int { 83 | groupedSections.count 84 | } 85 | 86 | open func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 87 | numberOfElements(in: section) 88 | } 89 | 90 | open func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 91 | let cell = tableView[indexPath] 92 | (cell as? TermsDataViewCell)?.load(element(in: indexPath)) 93 | return cell 94 | } 95 | } 96 | 97 | // MARK: - UICollectionView delegates 98 | 99 | extension TermsDataViewAdapter: UICollectionViewDelegate { 100 | 101 | open func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { 102 | collectionView.deselectItem(at: indexPath, animated: true) //Handle cell highlight 103 | 104 | delegate?.termsDataView( 105 | didSelect: element(in: indexPath), 106 | at: indexPath, 107 | from: collectionView 108 | ) 109 | } 110 | } 111 | 112 | extension TermsDataViewAdapter: UICollectionViewDataSource { 113 | 114 | open func numberOfSections(in collectionView: UICollectionView) -> Int { 115 | groupedSections.count 116 | } 117 | 118 | open func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { 119 | numberOfElements(in: section) 120 | } 121 | 122 | open func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { 123 | let cell = collectionView[indexPath] 124 | (cell as? TermsDataViewCell)?.load(element(in: indexPath)) 125 | return cell 126 | } 127 | } 128 | 129 | // MARK: - Helpers 130 | 131 | private extension TermsDataViewAdapter { 132 | 133 | func elements(in section: Int) -> [TermsDataViewModel] { 134 | groupedViewModels[groupedSections[section]] ?? [] 135 | } 136 | 137 | func element(in indexPath: IndexPath) -> TermsDataViewModel { 138 | elements(in: indexPath.section)[indexPath.row] 139 | } 140 | 141 | func numberOfElements(in section: Int) -> Int { 142 | elements(in: section).count 143 | } 144 | } 145 | 146 | // MARK: - Types 147 | 148 | public protocol TermsDataViewCell { 149 | func load(_ model: TermsDataViewModel) 150 | } 151 | #endif 152 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Views/UIKit/Controls/Layouts/SnapPagingLayout.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SnapPagingLayout.swift 3 | // Snap page and center collection view cell 4 | // https://medium.com/@shaibalassiano/tutorial-horizontal-uicollectionview-with-paging-9421b479ee94 5 | // 6 | // Created by Basem Emara on 2018-10-03. 7 | // Copyright © 2019 Zamzam Inc. All rights reserved. 8 | // 9 | 10 | #if os(iOS) 11 | import UIKit 12 | 13 | open class SnapPagingLayout: UICollectionViewFlowLayout { 14 | private var centerPosition = true 15 | private var peekWidth: CGFloat = 0 16 | private var indexOfCellBeforeDragging = 0 17 | 18 | public convenience init(centerPosition: Bool = true, peekWidth: CGFloat = 40, spacing: CGFloat? = nil, inset: CGFloat? = nil) { 19 | self.init() 20 | 21 | self.scrollDirection = .horizontal 22 | self.centerPosition = centerPosition 23 | self.peekWidth = peekWidth 24 | 25 | if let spacing = spacing { 26 | self.minimumLineSpacing = spacing 27 | } 28 | 29 | if let inset = inset { 30 | self.sectionInset = UIEdgeInsets(top: 0, left: inset, bottom: 0, right: inset) 31 | } 32 | } 33 | 34 | open override func prepare() { 35 | super.prepare() 36 | 37 | guard let collectionView = collectionView else { return } 38 | self.itemSize = calculateItemSize(from: collectionView.bounds.size) 39 | } 40 | 41 | open override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { 42 | guard let collectionView = collectionView, 43 | !newBounds.size.equalTo(collectionView.bounds.size) else { 44 | return false 45 | } 46 | 47 | itemSize = calculateItemSize(from: collectionView.bounds.size) 48 | return true 49 | } 50 | } 51 | 52 | private extension SnapPagingLayout { 53 | 54 | func calculateItemSize(from containerSize: CGSize) -> CGSize { 55 | CGSize( 56 | width: containerSize.width - peekWidth * 2, 57 | height: containerSize.height 58 | ) 59 | } 60 | 61 | func indexOfMajorCell() -> Int { 62 | guard let collectionView = collectionView else { return 0 } 63 | 64 | let proportionalOffset = collectionView.contentOffset.x 65 | / (itemSize.width + minimumLineSpacing) 66 | 67 | return Int(round(proportionalOffset)) 68 | } 69 | } 70 | 71 | extension SnapPagingLayout: ScrollableFlowLayout { 72 | 73 | open func willBeginDragging() { 74 | indexOfCellBeforeDragging = indexOfMajorCell() 75 | } 76 | 77 | open func willEndDragging(withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { 78 | guard let collectionView = collectionView else { return } 79 | 80 | // Stop scrollView sliding 81 | targetContentOffset.pointee = collectionView.contentOffset 82 | 83 | // Calculate where scrollView should snap to 84 | let indexOfMajorCell = self.indexOfMajorCell() 85 | 86 | guard let dataSourceCount = collectionView.dataSource?.collectionView(collectionView, numberOfItemsInSection: 0), 87 | dataSourceCount > 0 else { 88 | return 89 | } 90 | 91 | // Calculate conditions 92 | let swipeVelocityThreshold: CGFloat = 0.5 // After some trail and error 93 | let hasEnoughVelocityToSlideToTheNextCell = indexOfCellBeforeDragging + 1 < dataSourceCount && velocity.x > swipeVelocityThreshold 94 | let hasEnoughVelocityToSlideToThePreviousCell = indexOfCellBeforeDragging - 1 >= 0 && velocity.x < -swipeVelocityThreshold 95 | let majorCellIsTheCellBeforeDragging = indexOfMajorCell == indexOfCellBeforeDragging 96 | let didUseSwipeToSkipCell = majorCellIsTheCellBeforeDragging 97 | && (hasEnoughVelocityToSlideToTheNextCell || hasEnoughVelocityToSlideToThePreviousCell) 98 | 99 | guard didUseSwipeToSkipCell else { 100 | return collectionView.scrollToItem( 101 | at: IndexPath(row: indexOfMajorCell, section: 0), 102 | at: centerPosition ? .centeredHorizontally : .left, 103 | animated: true 104 | ) 105 | } 106 | 107 | let snapToIndex = indexOfCellBeforeDragging + (hasEnoughVelocityToSlideToTheNextCell ? 1 : -1) 108 | var toValue = CGFloat(snapToIndex) * (itemSize.width + minimumLineSpacing) 109 | 110 | if centerPosition { 111 | // Back up a bit to center 112 | toValue = (toValue - peekWidth + sectionInset.left) 113 | } 114 | 115 | // Damping equal 1 => no oscillations => decay animation 116 | UIView.animate( 117 | withDuration: 0.3, 118 | delay: 0, 119 | usingSpringWithDamping: 1, 120 | initialSpringVelocity: velocity.x, 121 | options: .allowUserInteraction, 122 | animations: { 123 | collectionView.contentOffset = CGPoint(x: toValue, y: 0) 124 | collectionView.layoutIfNeeded() 125 | }, 126 | completion: nil 127 | ) 128 | } 129 | } 130 | #endif 131 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Repositories/Author/Services/AuthorRealmCache.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthorRealmCache.swift 3 | // SwiftyPress 4 | // 5 | // Created by Basem Emara on 2018-10-20. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import RealmSwift 11 | import ZamzamCore 12 | 13 | public struct AuthorRealmCache: AuthorCache { 14 | private let log: LogRepository 15 | 16 | public init(log: LogRepository) { 17 | self.log = log 18 | } 19 | } 20 | 21 | public extension AuthorRealmCache { 22 | 23 | func fetch(with request: AuthorAPI.FetchRequest, completion: @escaping (Result) -> Void) { 24 | DispatchQueue.database.async { 25 | let realm: Realm 26 | 27 | do { 28 | realm = try Realm() 29 | } catch { 30 | DispatchQueue.main.async { completion(.failure(.databaseFailure(error))) } 31 | return 32 | } 33 | 34 | guard let object = realm.object(ofType: AuthorRealmObject.self, forPrimaryKey: request.id) else { 35 | DispatchQueue.main.async { completion(.failure(.nonExistent)) } 36 | return 37 | } 38 | 39 | let item = Author(from: object) 40 | 41 | DispatchQueue.main.async { 42 | completion(.success(item)) 43 | } 44 | } 45 | } 46 | } 47 | 48 | public extension AuthorRealmCache { 49 | 50 | func createOrUpdate(_ request: Author, completion: @escaping (Result) -> Void) { 51 | DispatchQueue.database.async { 52 | let realm: Realm 53 | 54 | do { 55 | realm = try Realm() 56 | } catch { 57 | DispatchQueue.main.async { completion(.failure(.databaseFailure(error))) } 58 | return 59 | } 60 | 61 | do { 62 | try realm.write { 63 | realm.add(AuthorRealmObject(from: request), update: .modified) 64 | } 65 | } catch { 66 | DispatchQueue.main.async { completion(.failure(.databaseFailure(error))) } 67 | return 68 | } 69 | 70 | // Get refreshed object to return 71 | guard let object = realm.object(ofType: AuthorRealmObject.self, forPrimaryKey: request.id) else { 72 | DispatchQueue.main.async { completion(.failure(.nonExistent)) } 73 | return 74 | } 75 | 76 | let item = Author(from: object) 77 | 78 | DispatchQueue.main.async { 79 | completion(.success(item)) 80 | } 81 | } 82 | } 83 | } 84 | 85 | public extension AuthorRealmCache { 86 | 87 | func subscribe( 88 | with request: AuthorAPI.FetchRequest, 89 | in cancellable: inout Cancellable?, 90 | change block: @escaping (ChangeResult) -> Void 91 | ) { 92 | DispatchQueue.database.sync { 93 | let realm: Realm 94 | 95 | do { 96 | realm = try Realm() 97 | } catch { 98 | self.log.error("An error occured while creating a Realm instance", error: error) 99 | DispatchQueue.main.async { block(.failure(.cacheFailure(error))) } 100 | return 101 | } 102 | 103 | cancellable = realm.objects(AuthorRealmObject.self) 104 | .filter("id == %@", request.id) 105 | .observe { changes in 106 | switch changes { 107 | case .initial(let list): 108 | guard let element = list.first else { return } 109 | self.log.debug("Author with id '\(request.id)' was initialized from cache") 110 | 111 | let item = Author(from: element) 112 | 113 | DispatchQueue.main.async { 114 | block(.initial(item)) 115 | } 116 | case .update(let list, let deletions, let insertions, let modifications): 117 | guard deletions.isEmpty else { 118 | self.log.debug("Author with id '\(request.id)' was deleted from cache") 119 | DispatchQueue.main.async { block(.failure(.nonExistent)) } 120 | return 121 | } 122 | 123 | guard let element = list.first else { return } 124 | 125 | let debugAction = !insertions.isEmpty ? "added" : !modifications.isEmpty ? "updated" : "unmodified" 126 | self.log.debug("Author with id '\(request.id)' was \(debugAction) in cache") 127 | 128 | let item = Author(from: element) 129 | 130 | DispatchQueue.main.async { 131 | block(.update(item)) 132 | } 133 | case .error(let error): 134 | self.log.error("An error occured while observing the Realm instance", error: error) 135 | DispatchQueue.main.async { block(.failure(.cacheFailure(error))) } 136 | } 137 | } 138 | } 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Repositories/Taxonomy/Services/TaxonomyRealmCache.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TaxonomyRealmCache.swift 3 | // SwiftyPress 4 | // 5 | // Created by Basem Emara on 2018-10-20. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import RealmSwift 11 | import ZamzamCore 12 | 13 | public struct TaxonomyRealmCache: TaxonomyCache { 14 | private let log: LogRepository 15 | 16 | public init(log: LogRepository) { 17 | self.log = log 18 | } 19 | } 20 | 21 | public extension TaxonomyRealmCache { 22 | 23 | func fetch(id: Int, completion: @escaping (Result) -> Void) { 24 | DispatchQueue.database.async { 25 | let realm: Realm 26 | 27 | do { 28 | realm = try Realm() 29 | } catch { 30 | DispatchQueue.main.async { completion(.failure(.databaseFailure(error))) } 31 | return 32 | } 33 | 34 | guard let object = realm.object(ofType: TermRealmObject.self, forPrimaryKey: id) else { 35 | DispatchQueue.main.async { completion(.failure(.nonExistent)) } 36 | return 37 | } 38 | 39 | let item = Term(from: object) 40 | 41 | DispatchQueue.main.async { 42 | completion(.success(item)) 43 | } 44 | } 45 | } 46 | 47 | func fetch(slug: String, completion: @escaping (Result) -> Void) { 48 | DispatchQueue.database.async { 49 | let realm: Realm 50 | 51 | do { 52 | realm = try Realm() 53 | } catch { 54 | DispatchQueue.main.async { completion(.failure(.databaseFailure(error))) } 55 | return 56 | } 57 | 58 | guard let object = realm.objects(TermRealmObject.self).filter("slug == %@", slug).first else { 59 | DispatchQueue.main.async { completion(.failure(.nonExistent)) } 60 | return 61 | } 62 | 63 | let item = Term(from: object) 64 | 65 | DispatchQueue.main.async { 66 | completion(.success(item)) 67 | } 68 | } 69 | } 70 | } 71 | 72 | public extension TaxonomyRealmCache { 73 | 74 | func fetch(completion: @escaping (Result<[Term], SwiftyPressError>) -> Void) { 75 | DispatchQueue.database.async { 76 | let realm: Realm 77 | 78 | do { 79 | realm = try Realm() 80 | } catch { 81 | DispatchQueue.main.async { completion(.failure(.databaseFailure(error))) } 82 | return 83 | } 84 | 85 | let items: [Term] = realm.objects(TermRealmObject.self) 86 | .filter("count > 0") 87 | .sorted(byKeyPath: "count", ascending: false) 88 | .map { Term(from: $0) } 89 | 90 | DispatchQueue.main.async { 91 | completion(.success(items)) 92 | } 93 | } 94 | } 95 | } 96 | 97 | public extension TaxonomyRealmCache { 98 | 99 | func fetch(ids: Set, completion: @escaping (Result<[Term], SwiftyPressError>) -> Void) { 100 | DispatchQueue.database.async { 101 | let realm: Realm 102 | 103 | do { 104 | realm = try Realm() 105 | } catch { 106 | DispatchQueue.main.async { completion(.failure(.databaseFailure(error))) } 107 | return 108 | } 109 | 110 | let items: [Term] = realm.objects(TermRealmObject.self, forPrimaryKeys: ids) 111 | .sorted(byKeyPath: "count", ascending: false) 112 | .map { Term(from: $0) } 113 | 114 | guard !items.isEmpty else { 115 | DispatchQueue.main.async { completion(.failure(.nonExistent)) } 116 | return 117 | } 118 | 119 | DispatchQueue.main.async { 120 | completion(.success(items)) 121 | } 122 | } 123 | } 124 | 125 | func fetch(by taxonomy: Taxonomy, completion: @escaping (Result<[Term], SwiftyPressError>) -> Void) { 126 | DispatchQueue.database.async { 127 | let realm: Realm 128 | 129 | do { 130 | realm = try Realm() 131 | } catch { 132 | DispatchQueue.main.async { completion(.failure(.databaseFailure(error))) } 133 | return 134 | } 135 | 136 | let items: [Term] = realm.objects(TermRealmObject.self) 137 | .filter("taxonomyRaw == %@ && count > 0", taxonomy.rawValue) 138 | .sorted(byKeyPath: "count", ascending: false) 139 | .map { Term(from: $0) } 140 | 141 | DispatchQueue.main.async { 142 | completion(.success(items)) 143 | } 144 | } 145 | } 146 | 147 | func fetch(by taxonomies: [Taxonomy], completion: @escaping (Result<[Term], SwiftyPressError>) -> Void) { 148 | DispatchQueue.database.async { 149 | let realm: Realm 150 | 151 | do { 152 | realm = try Realm() 153 | } catch { 154 | DispatchQueue.main.async { completion(.failure(.databaseFailure(error))) } 155 | return 156 | } 157 | 158 | let items: [Term] = realm.objects(TermRealmObject.self) 159 | .filter("taxonomyRaw IN %@ && count > 0", taxonomies.map { $0.rawValue }) 160 | .sorted(byKeyPath: "count", ascending: false) 161 | .map { Term(from: $0) } 162 | 163 | DispatchQueue.main.async { 164 | completion(.success(items)) 165 | } 166 | } 167 | } 168 | } 169 | 170 | public extension TaxonomyRealmCache { 171 | 172 | func getID(bySlug slug: String) -> Int? { 173 | let realm: Realm 174 | 175 | do { 176 | realm = try Realm() 177 | } catch { 178 | return nil 179 | } 180 | 181 | return realm.objects(TermRealmObject.self) 182 | .filter("slug == %@", slug) 183 | .first? 184 | .id 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Repositories/Taxonomy/TaxonomyRepository.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TaxonomyRepository.swift 3 | // SwiftPress 4 | // 5 | // Created by Basem Emara on 2018-05-29. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | import Foundation.NSURL 10 | 11 | public struct TaxonomyRepository { 12 | private let cache: TaxonomyCache 13 | private let dataRepository: DataRepository 14 | 15 | public init(cache: TaxonomyCache, dataRepository: DataRepository) { 16 | self.cache = cache 17 | self.dataRepository = dataRepository 18 | } 19 | } 20 | 21 | public extension TaxonomyRepository { 22 | 23 | func fetch(id: Int, completion: @escaping (Result) -> Void) { 24 | cache.fetch(id: id) { result in 25 | // Retrieve missing cache data from cloud if applicable 26 | if case .nonExistent? = result.error { 27 | // Sync remote updates to cache if applicable 28 | self.dataRepository.fetch { 29 | // Validate if any updates that needs to be stored 30 | guard case let .success(item) = $0, item.terms.contains(where: { $0.id == id }) else { 31 | completion(result) 32 | return 33 | } 34 | 35 | self.cache.fetch(id: id, completion: completion) 36 | } 37 | 38 | return 39 | } 40 | 41 | completion(result) 42 | } 43 | } 44 | 45 | func fetch(slug: String, completion: @escaping (Result) -> Void) { 46 | cache.fetch(slug: slug) { result in 47 | // Retrieve missing cache data from cloud if applicable 48 | if case .nonExistent? = result.error { 49 | // Sync remote updates to cache if applicable 50 | self.dataRepository.fetch { 51 | // Validate if any updates that needs to be stored 52 | guard case let .success(item) = $0, item.terms.contains(where: { $0.slug == slug }) else { 53 | completion(result) 54 | return 55 | } 56 | 57 | self.cache.fetch(slug: slug, completion: completion) 58 | } 59 | 60 | return 61 | } 62 | 63 | completion(result) 64 | } 65 | } 66 | 67 | func fetch(ids: Set, completion: @escaping (Result<[Term], SwiftyPressError>) -> Void) { 68 | cache.fetch(ids: ids) { result in 69 | // Retrieve missing cache data from cloud if applicable 70 | if case .nonExistent? = result.error { 71 | // Sync remote updates to cache if applicable 72 | self.dataRepository.fetch { 73 | // Validate if any updates that needs to be stored 74 | guard case let .success(item) = $0, item.terms.contains(where: { ids.contains($0.id) }) else { 75 | completion(result) 76 | return 77 | } 78 | 79 | self.cache.fetch(ids: ids, completion: completion) 80 | } 81 | 82 | return 83 | } 84 | 85 | completion(result) 86 | } 87 | } 88 | } 89 | 90 | public extension TaxonomyRepository { 91 | 92 | func fetch(completion: @escaping (Result<[Term], SwiftyPressError>) -> Void) { 93 | cache.fetch { 94 | // Immediately return local response 95 | completion($0) 96 | 97 | guard case .success = $0 else { return } 98 | 99 | // Sync remote updates to cache if applicable 100 | self.dataRepository.fetch { 101 | // Validate if any updates that needs to be stored 102 | guard case let .success(item) = $0, !item.terms.isEmpty else { 103 | return 104 | } 105 | 106 | self.cache.fetch(completion: completion) 107 | } 108 | } 109 | } 110 | 111 | func fetch(by taxonomy: Taxonomy, completion: @escaping (Result<[Term], SwiftyPressError>) -> Void) { 112 | cache.fetch(by: taxonomy) { 113 | // Immediately return local response 114 | completion($0) 115 | 116 | guard case .success = $0 else { return } 117 | 118 | // Sync remote updates to cache if applicable 119 | self.dataRepository.fetch { 120 | // Validate if any updates that needs to be stored 121 | guard case let .success(item) = $0, 122 | item.terms.contains(where: { $0.taxonomy == taxonomy }) else { 123 | return 124 | } 125 | 126 | self.cache.fetch(by: taxonomy, completion: completion) 127 | } 128 | } 129 | } 130 | 131 | func fetch(by taxonomies: [Taxonomy], completion: @escaping (Result<[Term], SwiftyPressError>) -> Void) { 132 | cache.fetch(by: taxonomies) { 133 | // Immediately return local response 134 | completion($0) 135 | 136 | guard case .success = $0 else { return } 137 | 138 | // Sync remote updates to cache if applicable 139 | self.dataRepository.fetch { 140 | // Validate if any updates that needs to be stored 141 | guard case let .success(item) = $0, 142 | item.terms.contains(where: { taxonomies.contains($0.taxonomy) }) else { 143 | return 144 | } 145 | 146 | self.cache.fetch(by: taxonomies, completion: completion) 147 | } 148 | } 149 | } 150 | } 151 | 152 | public extension TaxonomyRepository { 153 | 154 | func fetch(url: String, completion: @escaping (Result) -> Void) { 155 | guard let slug = slug(from: url) else { 156 | completion(.failure(.nonExistent)) 157 | return 158 | } 159 | 160 | fetch(slug: slug, completion: completion) 161 | } 162 | } 163 | 164 | public extension TaxonomyRepository { 165 | 166 | func getID(bySlug slug: String) -> Int? { 167 | cache.getID(bySlug: slug) 168 | } 169 | 170 | func getID(byURL url: String) -> Int? { 171 | guard let slug = slug(from: url) else { return nil } 172 | return getID(bySlug: slug) 173 | } 174 | } 175 | 176 | // MARK: - Helpers 177 | 178 | private extension TaxonomyRepository { 179 | 180 | func slug(from url: String) -> String? { 181 | guard let url = URL(string: url) else { return nil } 182 | 183 | let slug = url.lastPathComponent.lowercased() 184 | let relativePath = url.relativePath 185 | .trimmingCharacters(in: CharacterSet(charactersIn: "/")) 186 | .lowercased() 187 | 188 | return relativePath.hasPrefix("category/") || relativePath .hasPrefix("tag/") 189 | ? slug : nil 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/SwiftyPressCore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftyPressConfigurator.swift 3 | // SwiftyPress 4 | // 5 | // Created by Basem Emara on 2019-05-11. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved.v 7 | // 8 | 9 | import Foundation.NSFileManager 10 | import Foundation.NSJSONSerialization 11 | import Foundation.NSNotification 12 | import ZamzamCore 13 | import ZamzamUI 14 | 15 | public protocol SwiftyPressCore { 16 | func constants() -> Constants 17 | func constantsService() -> ConstantsService 18 | 19 | func preferences() -> Preferences 20 | func preferencesService() -> PreferencesService 21 | 22 | func log() -> LogRepository 23 | func logServices() -> [LogService] 24 | 25 | func networkRepository() -> NetworkRepository 26 | func networkService() -> NetworkService 27 | 28 | func dataRepository() -> DataRepository 29 | func dataService() -> DataService 30 | func dataCache() -> DataCache 31 | func dataSeed() -> DataSeed 32 | 33 | func postRepository() -> PostRepository 34 | func postService() -> PostService 35 | func postCache() -> PostCache 36 | 37 | func authorRepository() -> AuthorRepository 38 | func authorService() -> AuthorService 39 | func authorCache() -> AuthorCache? 40 | 41 | func mediaRepository() -> MediaRepository 42 | func mediaService() -> MediaService 43 | func mediaCache() -> MediaCache 44 | 45 | func taxonomyRepository() -> TaxonomyRepository 46 | func taxonomyCache() -> TaxonomyCache 47 | 48 | func favoriteRepository() -> FavoriteRepository 49 | 50 | func notificationCenter() -> NotificationCenter 51 | func fileManager() -> FileManager 52 | func jsonDecoder() -> JSONDecoder 53 | 54 | func theme() -> Theme 55 | 56 | #if os(iOS) 57 | @available(iOS 10.0, *) 58 | func mailComposer(delegate: MailComposerDelegate?) -> MailComposer 59 | #endif 60 | } 61 | 62 | // MARK: - Defaults 63 | 64 | public extension SwiftyPressCore { 65 | 66 | func constants() -> Constants { 67 | Constants(service: constantsService()) 68 | } 69 | } 70 | 71 | public extension SwiftyPressCore { 72 | 73 | func preferences() -> Preferences { 74 | Preferences(service: preferencesService()) 75 | } 76 | } 77 | 78 | public extension SwiftyPressCore { 79 | 80 | func log() -> LogRepository { 81 | LogRepository(services: logServices()) 82 | } 83 | } 84 | 85 | public extension SwiftyPressCore { 86 | 87 | func networkRepository() -> NetworkRepository { 88 | NetworkRepository(service: networkService()) 89 | } 90 | 91 | func networkService() -> NetworkService { 92 | NetworkFoundationService() 93 | } 94 | } 95 | 96 | public extension SwiftyPressCore { 97 | 98 | func dataRepository() -> DataRepository { 99 | DataRepository( 100 | dataService: dataService(), 101 | dataCache: dataCache(), 102 | dataSeed: dataSeed(), 103 | constants: constants(), 104 | log: log() 105 | ) 106 | } 107 | 108 | func dataService() -> DataService { 109 | DataNetworkService( 110 | networkRepository: networkRepository(), 111 | jsonDecoder: jsonDecoder(), 112 | constants: constants(), 113 | log: log() 114 | ) 115 | } 116 | 117 | func dataCache() -> DataCache { 118 | DataRealmCache( 119 | fileManager: fileManager(), 120 | preferences: preferences(), 121 | log: log() 122 | ) 123 | } 124 | } 125 | 126 | public extension SwiftyPressCore { 127 | 128 | func postRepository() -> PostRepository { 129 | PostRepository( 130 | service: postService(), 131 | cache: postCache(), 132 | dataRepository: dataRepository(), 133 | preferences: preferences(), 134 | constants: constants(), 135 | log: log() 136 | ) 137 | } 138 | 139 | func postService() -> PostService { 140 | PostNetworkService( 141 | networkRepository: networkRepository(), 142 | jsonDecoder: jsonDecoder(), 143 | constants: constants(), 144 | log: log() 145 | ) 146 | } 147 | 148 | func postCache() -> PostCache { 149 | PostRealmCache(log: log()) 150 | } 151 | } 152 | 153 | public extension SwiftyPressCore { 154 | 155 | func authorRepository() -> AuthorRepository { 156 | AuthorRepository( 157 | service: authorService(), 158 | cache: authorCache(), 159 | log: log() 160 | ) 161 | } 162 | 163 | func authorService() -> AuthorService { 164 | AuthorNetworkService( 165 | networkRepository: networkRepository(), 166 | jsonDecoder: jsonDecoder(), 167 | constants: constants(), 168 | log: log() 169 | ) 170 | } 171 | 172 | func authorCache() -> AuthorCache? { 173 | AuthorRealmCache(log: log()) 174 | } 175 | } 176 | 177 | public extension SwiftyPressCore { 178 | 179 | func mediaRepository() -> MediaRepository { 180 | MediaRepository( 181 | service: mediaService(), 182 | cache: mediaCache() 183 | ) 184 | } 185 | 186 | func mediaService() -> MediaService { 187 | MediaNetworkService( 188 | networkRepository: networkRepository(), 189 | jsonDecoder: jsonDecoder(), 190 | constants: constants(), 191 | log: log() 192 | ) 193 | } 194 | 195 | func mediaCache() -> MediaCache { 196 | MediaRealmCache(log: log()) 197 | } 198 | } 199 | 200 | public extension SwiftyPressCore { 201 | 202 | func taxonomyRepository() -> TaxonomyRepository { 203 | TaxonomyRepository( 204 | cache: taxonomyCache(), 205 | dataRepository: dataRepository() 206 | ) 207 | } 208 | 209 | func taxonomyCache() -> TaxonomyCache { 210 | TaxonomyRealmCache(log: log()) 211 | } 212 | } 213 | 214 | public extension SwiftyPressCore { 215 | 216 | func favoriteRepository() -> FavoriteRepository { 217 | FavoriteRepository( 218 | postRepository: postRepository(), 219 | preferences: preferences() 220 | ) 221 | } 222 | } 223 | 224 | public extension SwiftyPressCore { 225 | 226 | func notificationCenter() -> NotificationCenter { 227 | .default 228 | } 229 | 230 | func fileManager() -> FileManager { 231 | .default 232 | } 233 | 234 | func jsonDecoder() -> JSONDecoder { 235 | .default 236 | } 237 | } 238 | 239 | public extension SwiftyPressCore { 240 | 241 | #if os(iOS) 242 | @available(iOS 10.0, *) 243 | func mailComposer(delegate: MailComposerDelegate? = nil) -> MailComposer { 244 | let theme: Theme = self.theme() 245 | 246 | return MailComposer( 247 | delegate: delegate, 248 | styleNavigationBar: { 249 | $0.tintColor = theme.tint 250 | } 251 | ) 252 | } 253 | #endif 254 | } 255 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Repositories/Author/AuthorRepository.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthorRepository.swift 3 | // SwiftPress 4 | // 5 | // Created by Basem Emara on 2018-05-29. 6 | // Copyright © 2019 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | import Foundation.NSDate 10 | import ZamzamCore 11 | 12 | public struct AuthorRepository { 13 | private let service: AuthorService 14 | private let cache: AuthorCache? 15 | private let log: LogRepository 16 | 17 | public init(service: AuthorService, cache: AuthorCache?, log: LogRepository) { 18 | self.service = service 19 | self.cache = cache 20 | self.log = log 21 | } 22 | } 23 | 24 | // MARK: - Retrievals 25 | 26 | public extension AuthorRepository { 27 | 28 | /// Returns the specified author. 29 | /// 30 | /// authorRepository.fetch(with: request) { result in 31 | /// switch result { 32 | /// case .initial(let item): 33 | /// // prepare UI 34 | /// case .update(let item): 35 | /// // diff UI 36 | /// case .failure(let error): 37 | /// // present error 38 | /// } 39 | /// } 40 | /// 41 | /// This returns the data from the cache immediately in the `.initial` case, but then checks the remote service 42 | /// for updates one time by comparing against the data residing in the cache and calls the `.update` case if there 43 | /// is a change from the initial state. This fetches from the cache and service one time only. 44 | /// 45 | /// - Parameters: 46 | /// - request: The request of the fetch query. 47 | /// - completion: The block to execute with the initial and updated results if available. 48 | func fetch(with request: AuthorAPI.FetchRequest, change completion: @escaping (ChangeResult) -> Void) { 49 | guard let cache = cache else { 50 | service.fetch(with: request, completion: { $0(completion) }) 51 | return 52 | } 53 | 54 | cache.fetch(with: request) { result in 55 | if case .nonExistent? = result.error { 56 | self.log.debug("Local cache empty for author, refreshing...") 57 | self.refresh(with: request, completion: { $0(completion) }) 58 | return 59 | } 60 | 61 | guard case let .success(cacheItem) = result else { 62 | completion(.failure(result.error ?? .cacheFailure(nil))) 63 | return 64 | } 65 | 66 | self.log.debug("Immediately return cached response and check remote service...") 67 | completion(.initial(cacheItem)) 68 | 69 | self.service.fetch(with: request) { result in 70 | guard case let .success(item) = result else { return } 71 | 72 | // Validate if any updates occurred and return 73 | guard item != cacheItem else { return } 74 | 75 | self.log.debug("Service data updates exist, saving to cache...") 76 | 77 | // Update local storage with updated data 78 | cache.createOrUpdate(item) { result in 79 | guard case .success = result else { return } 80 | completion(.update(item)) 81 | } 82 | } 83 | } 84 | } 85 | 86 | /// Requests the latest author from the service. 87 | /// 88 | /// This updates the cache if there is newer data from the service. If there are 89 | /// any observers subscribed to the cache, they will be notified of the update. 90 | /// 91 | /// - Parameters: 92 | /// - request: The request of the fetch query. 93 | /// - cacheExpiry: The cache expiry time in seconds to throttle requests before making new requests. If `0` is specified, the service is requested every time. 94 | func fetch(with request: AuthorAPI.FetchRequest, cacheExpiry: Int = 0) { 95 | refresh(with: request, cacheExpiry: cacheExpiry) 96 | } 97 | } 98 | 99 | // MARK: - Observables 100 | 101 | public extension AuthorRepository { 102 | 103 | /// Registers a block to be called each time the author changes or is removed. 104 | /// 105 | /// authorRepository 106 | /// .subscribe(with: request) { [weak self] result in 107 | /// switch result { 108 | /// case .initial(let item): 109 | /// // prepare UI 110 | /// case .update(let item): 111 | /// // diff UI 112 | /// case .failure(let error): 113 | /// // present error 114 | /// } 115 | /// } 116 | /// .store(in: &self.cancellable) 117 | /// 118 | /// The block will be asynchronously called with the initial results in the `.initial` case , and then called again 119 | /// after each cache update from the service in the `.update` case . Call `fetch` to trigger a call to the service 120 | /// to check for updates, otherwise only the initial results from the cache is returned. 121 | /// 122 | /// - Parameters: 123 | /// - request: The request of the fetch query. 124 | /// - block: The block to be called with the initial state and whenever a change occurs. 125 | /// - Returns: Returns an object to registister a token. Call `store` on the returned 126 | /// object with a token, which must be held for as long as you want updates to be delivered. 127 | func subscribe( 128 | with request: AuthorAPI.FetchRequest, 129 | change block: @escaping (ChangeResult) -> Void 130 | ) -> AuthorAPI.FetchCancellable { 131 | AuthorAPI.FetchCancellable( 132 | service: service, 133 | cache: cache, 134 | request: request, 135 | change: block 136 | ) 137 | } 138 | } 139 | 140 | // MARK: - Helpers 141 | 142 | private extension AuthorRepository { 143 | static var lastFetchPageRefreshed = Atomic<[AnyHashable: Date]>([:]) 144 | 145 | func refresh(with request: AuthorAPI.FetchRequest, cacheExpiry: Int = 0, completion: ((Result) -> Void)? = nil) { 146 | // Skip if cache refreshed from server within expiry time window 147 | guard cacheExpiry == 0 || Date().isBeyond(Self.lastFetchPageRefreshed.value[request] ?? .distantPast, bySeconds: cacheExpiry) else { 148 | self.log.debug("Skipped refreshed cache from the service for author") 149 | return 150 | } 151 | 152 | // Remember last cached date to throttle requests 153 | Self.lastFetchPageRefreshed.value { $0[request] = Date() } 154 | 155 | guard let cache = cache else { 156 | service.fetch(with: request, completion: completion ?? { _ in }) 157 | return 158 | } 159 | 160 | service.fetch(with: request) { result in 161 | guard case let .success(item) = result else { 162 | completion?(result) 163 | return 164 | } 165 | 166 | cache.createOrUpdate(item) { result in 167 | defer { completion?(result) } 168 | guard case .success = result else { return } 169 | self.log.debug("Successfully refreshed cache from the service for author") 170 | } 171 | } 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /Sources/SwiftyPress/Views/UIKit/Controls/DataViews/PostsDataView/PostsDataViewAdapter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PostDataViewAdapter.swift 3 | // Basem Emara 4 | // 5 | // Created by Basem Emara on 2018-06-20. 6 | // Copyright © 2018 Zamzam Inc. All rights reserved. 7 | // 8 | 9 | #if os(iOS) 10 | import Foundation 11 | import UIKit 12 | import ZamzamCore 13 | import ZamzamUI 14 | 15 | open class PostsDataViewAdapter: NSObject { 16 | private let dataView: DataViewable 17 | private weak var delegate: PostsDataViewDelegate? 18 | public private(set) var viewModels: [PostsDataViewModel]? 19 | 20 | public init(for dataView: DataViewable, delegate: PostsDataViewDelegate? = nil) { 21 | self.dataView = dataView 22 | self.delegate = delegate 23 | 24 | super.init() 25 | 26 | // Set data view delegates 27 | if let tableView = dataView as? UITableView { 28 | tableView.delegate = self 29 | tableView.dataSource = self 30 | } else if let collectionView = dataView as? UICollectionView { 31 | collectionView.delegate = self 32 | collectionView.dataSource = self 33 | } 34 | } 35 | } 36 | 37 | extension PostsDataViewAdapter { 38 | 39 | open func reloadData(with viewModels: [PostsDataViewModel]) { 40 | self.viewModels = viewModels 41 | dataView.reloadData() 42 | delegate?.postsDataViewDidReloadData() 43 | } 44 | } 45 | 46 | // MARK: - UITableView delegates 47 | 48 | extension PostsDataViewAdapter: UITableViewDelegate { 49 | 50 | open func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 51 | tableView.deselectRow(at: indexPath, animated: true) //Handle cell highlight 52 | 53 | guard let model = viewModels?[indexPath.row] else { return } 54 | 55 | delegate?.postsDataView( 56 | didSelect: model, 57 | at: indexPath, 58 | from: tableView 59 | ) 60 | } 61 | 62 | open func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { 63 | guard let model = viewModels?[indexPath.row] else { return nil } 64 | return delegate?.postsDataView(leadingSwipeActionsFor: model, at: indexPath, from: tableView) 65 | } 66 | 67 | open func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { 68 | guard let model = viewModels?[indexPath.row] else { return nil } 69 | return delegate?.postsDataView(trailingSwipeActionsFor: model, at: indexPath, from: tableView) 70 | } 71 | 72 | @available(iOS 13, *) 73 | open func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { 74 | guard let model = viewModels?[indexPath.row] else { return nil } 75 | return delegate?.postsDataView(contextMenuConfigurationFor: model, at: indexPath, point: point, from: tableView) 76 | } 77 | 78 | @available(iOS 13, *) 79 | open func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { 80 | animator.addCompletion { [weak self] in 81 | guard let id = configuration.identifier as? Int, 82 | let model = self?.viewModels?.first(where: { $0.id == id }) else { 83 | return 84 | } 85 | 86 | self?.delegate?.postsDataView(didPerformPreviewActionFor: model, from: tableView) 87 | } 88 | } 89 | } 90 | 91 | extension PostsDataViewAdapter: UITableViewDataSource { 92 | 93 | open func numberOfSections(in tableView: UITableView) -> Int { 94 | delegate?.postsDataViewNumberOfSections(in: tableView) ?? 1 95 | } 96 | 97 | open func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 98 | viewModels?.count ?? 0 99 | } 100 | 101 | open func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 102 | let cell = tableView[indexPath] 103 | guard let model = viewModels?[indexPath.row] else { return cell } 104 | (cell as? PostsDataViewCell)?.load(model, delegate: delegate) 105 | return cell 106 | } 107 | } 108 | 109 | // MARK: - UICollectionView delegates 110 | 111 | extension PostsDataViewAdapter: UICollectionViewDelegate { 112 | 113 | open func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { 114 | collectionView.deselectItem(at: indexPath, animated: true) //Handle cell highlight 115 | 116 | guard let model = viewModels?[indexPath.row] else { return } 117 | 118 | delegate?.postsDataView( 119 | didSelect: model, 120 | at: indexPath, 121 | from: collectionView 122 | ) 123 | } 124 | 125 | @available(iOS 13, *) 126 | open func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { 127 | guard let model = viewModels?[indexPath.row] else { return nil } 128 | return delegate?.postsDataView(contextMenuConfigurationFor: model, at: indexPath, point: point, from: collectionView) 129 | } 130 | 131 | @available(iOS 13, *) 132 | open func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { 133 | animator.addCompletion { [weak self] in 134 | guard let id = configuration.identifier as? Int, 135 | let model = self?.viewModels?.first(where: { $0.id == id }) else { 136 | return 137 | } 138 | 139 | self?.delegate?.postsDataView(didPerformPreviewActionFor: model, from: collectionView) 140 | } 141 | } 142 | } 143 | 144 | extension PostsDataViewAdapter: UICollectionViewDataSource { 145 | 146 | open func numberOfSections(in collectionView: UICollectionView) -> Int { 147 | delegate?.postsDataViewNumberOfSections(in: collectionView) ?? 1 148 | } 149 | 150 | open func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { 151 | viewModels?.count ?? 0 152 | } 153 | 154 | open func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { 155 | let cell = collectionView[indexPath] 156 | guard let model = viewModels?[indexPath.row] else { return cell } 157 | (cell as? PostsDataViewCell)?.load(model, delegate: delegate) 158 | return cell 159 | } 160 | } 161 | 162 | // MARK: - UIScrollView delegates 163 | 164 | extension PostsDataViewAdapter { 165 | 166 | open func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { 167 | delegate?.postsDataViewWillBeginDragging(scrollView) 168 | } 169 | 170 | open func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { 171 | delegate?.postsDataViewWillEndDragging(scrollView, withVelocity: velocity, targetContentOffset: targetContentOffset) 172 | } 173 | } 174 | 175 | // MARK: - Types 176 | 177 | public protocol PostsDataViewCell { 178 | func load(_ model: PostsDataViewModel) 179 | func load(_ model: PostsDataViewModel, delegate: PostsDataViewDelegate?) 180 | } 181 | 182 | public extension PostsDataViewCell { 183 | 184 | func load(_ model: PostsDataViewModel, delegate: PostsDataViewDelegate?) { 185 | load(model) 186 | } 187 | } 188 | #endif 189 | --------------------------------------------------------------------------------