├── .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 | [](https://github.com/ZamzamInc/ZamzamKit)
4 | [](https://swift.org)
5 | [](https://developer.apple.com/xcode)
6 | [](https://swift.org/package-manager)
7 | [](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 |
--------------------------------------------------------------------------------