├── Design
├── Chuck.sketch
└── ChuckIcon.sketch
├── ChuckCore
├── TestUtilities
│ ├── Resources
│ │ ├── searchEmpty.json
│ │ ├── random.json
│ │ ├── categories.json
│ │ ├── randomWithCategory.json
│ │ └── search.json
│ ├── TestArguments.swift
│ ├── ChuckCoreBundle.swift
│ ├── UITestingLabel.swift
│ └── ChuckAPIEnvironment+Test.swift
├── Models
│ ├── API
│ │ ├── SearchResponse.swift
│ │ ├── Category.swift
│ │ └── Joke.swift
│ └── User Generated
│ │ └── RecentSearch.swift
├── Persistence
│ ├── UserDefaults+.swift
│ ├── NSManagedObjectContext+.swift
│ ├── Category+Persistence.swift
│ ├── ChuckCorePersistentContainer.swift
│ ├── RecentSearch+Persistence.swift
│ ├── Model.xcdatamodeld
│ │ └── Model.xcdatamodel
│ │ │ └── contents
│ └── Joke+Persistence.swift
├── UI Support
│ ├── Util
│ │ ├── ObservableType+Reachability.swift
│ │ ├── Collection+Shuffle.swift
│ │ └── Reachability+Rx.swift
│ ├── ViewModels
│ │ ├── CategoryViewModel.swift
│ │ ├── RecentSearchViewModel.swift
│ │ └── JokeViewModel.swift
│ ├── ChuckUIStack.swift
│ └── SyncEngine.swift
├── ChuckCore.h
├── Info.plist
└── Networking
│ ├── ChuckAPIEnvironment.swift
│ ├── ChuckAPIEndpoint.swift
│ └── ChuckAPIClient.swift
├── Screenshots
├── 1-home.png
├── 3-share.png
└── 2-search.png
├── Chuck
├── Resources
│ ├── Assets.xcassets
│ │ ├── Contents.json
│ │ ├── share.imageset
│ │ │ ├── share.pdf
│ │ │ └── Contents.json
│ │ ├── search.imageset
│ │ │ ├── search.pdf
│ │ │ └── Contents.json
│ │ └── AppIcon.appiconset
│ │ │ ├── Icon.png
│ │ │ ├── Icon-40.png
│ │ │ ├── Icon-72.png
│ │ │ ├── Icon-76.png
│ │ │ ├── Icon@2x.png
│ │ │ ├── Icon-40@2x.png
│ │ │ ├── Icon-40@3x.png
│ │ │ ├── Icon-60@2x.png
│ │ │ ├── Icon-60@3x.png
│ │ │ ├── Icon-72@2x.png
│ │ │ ├── Icon-76@2x.png
│ │ │ ├── Icon-Small.png
│ │ │ ├── Icon-83.5@2x.png
│ │ │ ├── Icon-Small-50.png
│ │ │ ├── Icon-Small@2x.png
│ │ │ ├── Icon-Small@3x.png
│ │ │ ├── ios-marketing.png
│ │ │ ├── Icon-Small-50@2x.png
│ │ │ ├── NotificationIcon@2x.png
│ │ │ ├── NotificationIcon@3x.png
│ │ │ ├── NotificationIcon~ipad.png
│ │ │ ├── NotificationIcon~ipad@2x.png
│ │ │ └── Contents.json
│ ├── Info.plist
│ ├── Base.lproj
│ │ └── LaunchScreen.storyboard
│ └── Intents.intentdefinition
├── Chuck.entitlements
├── Definitions
│ ├── Fonts.swift
│ ├── Metrics.swift
│ ├── Colors.swift
│ └── Messages.swift
├── Controllers
│ ├── Extensions
│ │ └── UIViewController+Children.swift
│ ├── Search
│ │ ├── Transition
│ │ │ ├── SearchTransition.swift
│ │ │ └── SearchPresentationDriver.swift
│ │ ├── BadgesCollectionViewController.swift
│ │ ├── SearchSuggestionsViewController.swift
│ │ └── SearchViewController.swift
│ ├── Home
│ │ ├── EmptyViewController.swift
│ │ └── ListJokesViewController.swift
│ └── AppFlowController.swift
├── Views
│ ├── SelfSizingCollectionView.swift
│ ├── BadgeFlowLayout.swift
│ ├── BadgeCollectionViewCell.swift
│ ├── BadgeView.swift
│ └── JokeTableViewCell.swift
└── Bootstrap
│ └── AppDelegate.swift
├── Cartfile
├── Cartfile.resolved
├── .travis.yml
├── .gitignore
├── ChuckIntents
├── ChuckIntents.entitlements
├── IntentHandler.swift
├── TellJokeIntentHandler.swift
└── Info.plist
├── TodayExtension
├── TodayExtension.entitlements
├── Info.plist
├── Base.lproj
│ └── MainInterface.storyboard
└── TodayViewController.swift
├── TellJokeIntentsUI
├── TellJokeIntentsUI.entitlements
├── Info.plist
├── IntentViewController.swift
└── Base.lproj
│ └── MainInterface.storyboard
├── ChuckCoreTests
├── Util
│ ├── SearchResponse+Sample.swift
│ ├── TestBundle.swift
│ ├── Joke+Sample.swift
│ ├── NSPersistentContainer+Test.swift
│ └── DecodingErrorExtension.swift
├── Info.plist
├── ChuckCoreViewModelTests.swift
├── ChuckCoreAPIEndpointTests.swift
├── ChuckCorePersistenceTests.swift
├── ChuckCoreAPIResponseParsingTests.swift
├── ChuckCoreAPIClientTests.swift
└── ChuckCoreSyncEngineTests.swift
├── ChuckUITests
├── Info.plist
└── ChuckUITests.swift
├── LICENSE
└── README.MD
/Design/Chuck.sketch:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/insidegui/Chuck/HEAD/Design/Chuck.sketch
--------------------------------------------------------------------------------
/ChuckCore/TestUtilities/Resources/searchEmpty.json:
--------------------------------------------------------------------------------
1 | {
2 | "total": 0,
3 | "result": []
4 | }
--------------------------------------------------------------------------------
/Design/ChuckIcon.sketch:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/insidegui/Chuck/HEAD/Design/ChuckIcon.sketch
--------------------------------------------------------------------------------
/Screenshots/1-home.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/insidegui/Chuck/HEAD/Screenshots/1-home.png
--------------------------------------------------------------------------------
/Screenshots/3-share.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/insidegui/Chuck/HEAD/Screenshots/3-share.png
--------------------------------------------------------------------------------
/Screenshots/2-search.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/insidegui/Chuck/HEAD/Screenshots/2-search.png
--------------------------------------------------------------------------------
/Chuck/Resources/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
--------------------------------------------------------------------------------
/Chuck/Resources/Assets.xcassets/share.imageset/share.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/insidegui/Chuck/HEAD/Chuck/Resources/Assets.xcassets/share.imageset/share.pdf
--------------------------------------------------------------------------------
/Chuck/Resources/Assets.xcassets/search.imageset/search.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/insidegui/Chuck/HEAD/Chuck/Resources/Assets.xcassets/search.imageset/search.pdf
--------------------------------------------------------------------------------
/Chuck/Resources/Assets.xcassets/AppIcon.appiconset/Icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/insidegui/Chuck/HEAD/Chuck/Resources/Assets.xcassets/AppIcon.appiconset/Icon.png
--------------------------------------------------------------------------------
/Chuck/Resources/Assets.xcassets/AppIcon.appiconset/Icon-40.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/insidegui/Chuck/HEAD/Chuck/Resources/Assets.xcassets/AppIcon.appiconset/Icon-40.png
--------------------------------------------------------------------------------
/Chuck/Resources/Assets.xcassets/AppIcon.appiconset/Icon-72.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/insidegui/Chuck/HEAD/Chuck/Resources/Assets.xcassets/AppIcon.appiconset/Icon-72.png
--------------------------------------------------------------------------------
/Chuck/Resources/Assets.xcassets/AppIcon.appiconset/Icon-76.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/insidegui/Chuck/HEAD/Chuck/Resources/Assets.xcassets/AppIcon.appiconset/Icon-76.png
--------------------------------------------------------------------------------
/Chuck/Resources/Assets.xcassets/AppIcon.appiconset/Icon@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/insidegui/Chuck/HEAD/Chuck/Resources/Assets.xcassets/AppIcon.appiconset/Icon@2x.png
--------------------------------------------------------------------------------
/Chuck/Resources/Assets.xcassets/AppIcon.appiconset/Icon-40@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/insidegui/Chuck/HEAD/Chuck/Resources/Assets.xcassets/AppIcon.appiconset/Icon-40@2x.png
--------------------------------------------------------------------------------
/Chuck/Resources/Assets.xcassets/AppIcon.appiconset/Icon-40@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/insidegui/Chuck/HEAD/Chuck/Resources/Assets.xcassets/AppIcon.appiconset/Icon-40@3x.png
--------------------------------------------------------------------------------
/Chuck/Resources/Assets.xcassets/AppIcon.appiconset/Icon-60@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/insidegui/Chuck/HEAD/Chuck/Resources/Assets.xcassets/AppIcon.appiconset/Icon-60@2x.png
--------------------------------------------------------------------------------
/Chuck/Resources/Assets.xcassets/AppIcon.appiconset/Icon-60@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/insidegui/Chuck/HEAD/Chuck/Resources/Assets.xcassets/AppIcon.appiconset/Icon-60@3x.png
--------------------------------------------------------------------------------
/Chuck/Resources/Assets.xcassets/AppIcon.appiconset/Icon-72@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/insidegui/Chuck/HEAD/Chuck/Resources/Assets.xcassets/AppIcon.appiconset/Icon-72@2x.png
--------------------------------------------------------------------------------
/Chuck/Resources/Assets.xcassets/AppIcon.appiconset/Icon-76@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/insidegui/Chuck/HEAD/Chuck/Resources/Assets.xcassets/AppIcon.appiconset/Icon-76@2x.png
--------------------------------------------------------------------------------
/Chuck/Resources/Assets.xcassets/AppIcon.appiconset/Icon-Small.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/insidegui/Chuck/HEAD/Chuck/Resources/Assets.xcassets/AppIcon.appiconset/Icon-Small.png
--------------------------------------------------------------------------------
/Cartfile:
--------------------------------------------------------------------------------
1 | github "ReactiveX/RxSwift" ~> 4.0
2 | github "RxSwiftCommunity/RxDataSources" ~> 3.0
3 | github "RxSwiftCommunity/RxCoreData" ~> 0.4.0
4 | github "ashleymills/Reachability.swift"
--------------------------------------------------------------------------------
/Chuck/Resources/Assets.xcassets/AppIcon.appiconset/Icon-83.5@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/insidegui/Chuck/HEAD/Chuck/Resources/Assets.xcassets/AppIcon.appiconset/Icon-83.5@2x.png
--------------------------------------------------------------------------------
/Chuck/Resources/Assets.xcassets/AppIcon.appiconset/Icon-Small-50.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/insidegui/Chuck/HEAD/Chuck/Resources/Assets.xcassets/AppIcon.appiconset/Icon-Small-50.png
--------------------------------------------------------------------------------
/Chuck/Resources/Assets.xcassets/AppIcon.appiconset/Icon-Small@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/insidegui/Chuck/HEAD/Chuck/Resources/Assets.xcassets/AppIcon.appiconset/Icon-Small@2x.png
--------------------------------------------------------------------------------
/Chuck/Resources/Assets.xcassets/AppIcon.appiconset/Icon-Small@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/insidegui/Chuck/HEAD/Chuck/Resources/Assets.xcassets/AppIcon.appiconset/Icon-Small@3x.png
--------------------------------------------------------------------------------
/Chuck/Resources/Assets.xcassets/AppIcon.appiconset/ios-marketing.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/insidegui/Chuck/HEAD/Chuck/Resources/Assets.xcassets/AppIcon.appiconset/ios-marketing.png
--------------------------------------------------------------------------------
/Chuck/Resources/Assets.xcassets/AppIcon.appiconset/Icon-Small-50@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/insidegui/Chuck/HEAD/Chuck/Resources/Assets.xcassets/AppIcon.appiconset/Icon-Small-50@2x.png
--------------------------------------------------------------------------------
/Chuck/Resources/Assets.xcassets/AppIcon.appiconset/NotificationIcon@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/insidegui/Chuck/HEAD/Chuck/Resources/Assets.xcassets/AppIcon.appiconset/NotificationIcon@2x.png
--------------------------------------------------------------------------------
/Chuck/Resources/Assets.xcassets/AppIcon.appiconset/NotificationIcon@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/insidegui/Chuck/HEAD/Chuck/Resources/Assets.xcassets/AppIcon.appiconset/NotificationIcon@3x.png
--------------------------------------------------------------------------------
/Chuck/Resources/Assets.xcassets/AppIcon.appiconset/NotificationIcon~ipad.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/insidegui/Chuck/HEAD/Chuck/Resources/Assets.xcassets/AppIcon.appiconset/NotificationIcon~ipad.png
--------------------------------------------------------------------------------
/Cartfile.resolved:
--------------------------------------------------------------------------------
1 | github "ReactiveX/RxSwift" "4.5.0"
2 | github "RxSwiftCommunity/RxCoreData" "0.4.0"
3 | github "RxSwiftCommunity/RxDataSources" "3.1.0"
4 | github "ashleymills/Reachability.swift" "v4.3.1"
5 |
--------------------------------------------------------------------------------
/Chuck/Resources/Assets.xcassets/AppIcon.appiconset/NotificationIcon~ipad@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/insidegui/Chuck/HEAD/Chuck/Resources/Assets.xcassets/AppIcon.appiconset/NotificationIcon~ipad@2x.png
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | os: osx
2 | osx_image: xcode9.3
3 | script:
4 | - xcodebuild -version
5 | - carthage version
6 | - carthage bootstrap --platform iOS
7 | - set -o pipefail && env "NSUnbufferedIO=YES" xcodebuild test -scheme Chuck -project Chuck.xcodeproj CODE_SIGNING_REQUIRED=NO | xcpretty -f `xcpretty-travis-formatter`
--------------------------------------------------------------------------------
/ChuckCore/TestUtilities/Resources/random.json:
--------------------------------------------------------------------------------
1 | {
2 | "category": null,
3 | "icon_url": "https://assets.chucknorris.host/img/avatar/chuck-norris.png",
4 | "id": "2cZa3wC8Ts-UI4TiFaFQVw",
5 | "url": "https://api.chucknorris.io/jokes/2cZa3wC8Ts-UI4TiFaFQVw",
6 | "value": "Chuck Norris is Mysterion."
7 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | __MACOSX
3 | *.pbxuser
4 | !default.pbxuser
5 | *.mode1v3
6 | !default.mode1v3
7 | *.mode2v3
8 | !default.mode2v3
9 | *.perspectivev3
10 | !default.perspectivev3
11 | *.xcworkspace
12 | !default.xcworkspace
13 | xcuserdata
14 | profile
15 | *.moved-aside
16 | DerivedData
17 | .idea/
18 | Pods/
19 | Carthage
--------------------------------------------------------------------------------
/ChuckCore/TestUtilities/Resources/categories.json:
--------------------------------------------------------------------------------
1 | [
2 | "explicit",
3 | "dev",
4 | "movie",
5 | "food",
6 | "celebrity",
7 | "science",
8 | "sport",
9 | "political",
10 | "religion",
11 | "animal",
12 | "history",
13 | "music",
14 | "travel",
15 | "career",
16 | "money",
17 | "fashion"
18 | ]
--------------------------------------------------------------------------------
/ChuckCore/Models/API/SearchResponse.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SearchResponse.swift
3 | // ChuckCore
4 | //
5 | // Created by Guilherme Rambo on 22/05/18.
6 | // Copyright © 2018 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public struct SearchResponse: Codable {
12 | public let total: Int
13 | public let result: [Joke]
14 | }
15 |
--------------------------------------------------------------------------------
/ChuckIntents/ChuckIntents.entitlements:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | com.apple.security.application-groups
6 |
7 | group.codes.rambo.Chuck
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/TodayExtension/TodayExtension.entitlements:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | com.apple.security.application-groups
6 |
7 | group.codes.rambo.Chuck
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/TellJokeIntentsUI/TellJokeIntentsUI.entitlements:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | com.apple.security.application-groups
6 |
7 | group.codes.rambo.Chuck
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/Chuck/Resources/Assets.xcassets/search.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "filename" : "search.pdf"
6 | }
7 | ],
8 | "info" : {
9 | "version" : 1,
10 | "author" : "xcode"
11 | },
12 | "properties" : {
13 | "template-rendering-intent" : "template",
14 | "preserves-vector-representation" : true
15 | }
16 | }
--------------------------------------------------------------------------------
/Chuck/Resources/Assets.xcassets/share.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "filename" : "share.pdf"
6 | }
7 | ],
8 | "info" : {
9 | "version" : 1,
10 | "author" : "xcode"
11 | },
12 | "properties" : {
13 | "template-rendering-intent" : "template",
14 | "preserves-vector-representation" : true
15 | }
16 | }
--------------------------------------------------------------------------------
/ChuckCore/TestUtilities/Resources/randomWithCategory.json:
--------------------------------------------------------------------------------
1 | {
2 | "category": [
3 | "political"
4 | ],
5 | "icon_url": "https://assets.chucknorris.host/img/avatar/chuck-norris.png",
6 | "id": "uanj8roxsrwq6pgb0kufia",
7 | "url": "https://api.chucknorris.io/jokes/uanj8roxsrwq6pgb0kufia",
8 | "value": "Guantuanamo Bay, Cuba, is the military code-word for \u0022Chuck Norris\u0027 basement\u0022."
9 | }
--------------------------------------------------------------------------------
/ChuckIntents/IntentHandler.swift:
--------------------------------------------------------------------------------
1 | //
2 | // IntentHandler.swift
3 | // ChuckIntents
4 | //
5 | // Created by Guilherme Rambo on 23/04/19.
6 | // Copyright © 2019 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import Intents
10 |
11 | class IntentHandler: INExtension {
12 |
13 | override func handler(for intent: INIntent) -> Any {
14 | return TellJokeIntentHandler()
15 | }
16 |
17 | }
18 |
--------------------------------------------------------------------------------
/ChuckCore/TestUtilities/TestArguments.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TestArguments.swift
3 | // ChuckCore
4 | //
5 | // Created by Guilherme Rambo on 28/05/18.
6 | // Copyright © 2018 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public final class TestArguments {
12 |
13 | public static var isRunningUITests: Bool {
14 | return ProcessInfo.processInfo.arguments.contains("--uitests")
15 | }
16 |
17 | }
18 |
--------------------------------------------------------------------------------
/ChuckCore/TestUtilities/ChuckCoreBundle.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ChuckCoreBundle.swift
3 | // ChuckCore
4 | //
5 | // Created by Guilherme Rambo on 27/05/18.
6 | // Copyright © 2018 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | private final class _StubForBundleInitialization { }
12 |
13 | extension Bundle {
14 |
15 | public static let chuckCore: Bundle = {
16 | return Bundle(for: _StubForBundleInitialization.self)
17 | }()
18 |
19 | }
20 |
--------------------------------------------------------------------------------
/Chuck/Chuck.entitlements:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | com.apple.security.application-groups
6 |
7 | group.codes.rambo.Chuck
8 |
9 | keychain-access-groups
10 |
11 | $(AppIdentifierPrefix)br.com.guilhermerambo.Chuck
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/ChuckCore/Models/API/Category.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Category.swift
3 | // ChuckCore
4 | //
5 | // Created by Guilherme Rambo on 27/05/18.
6 | // Copyright © 2018 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public struct Category: Codable, Equatable {
12 | public let name: String
13 |
14 | public init(from decoder: Decoder) throws {
15 | let container = try decoder.singleValueContainer()
16 | name = try container.decode(String.self)
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/ChuckCore/Persistence/UserDefaults+.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UserDefaults+.swift
3 | // ChuckCore
4 | //
5 | // Created by Guilherme Rambo on 15/04/19.
6 | // Copyright © 2019 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public extension UserDefaults {
12 |
13 | static let chuckApplicationGroup = "group.codes.rambo.Chuck"
14 |
15 | static var groupDefaults: UserDefaults {
16 | return UserDefaults(suiteName: UserDefaults.chuckApplicationGroup) ?? .standard
17 | }
18 |
19 | }
20 |
--------------------------------------------------------------------------------
/ChuckCore/Models/User Generated/RecentSearch.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RecentSearch.swift
3 | // ChuckCore
4 | //
5 | // Created by Guilherme Rambo on 27/05/18.
6 | // Copyright © 2018 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public struct RecentSearch: Equatable {
12 |
13 | public let createdAt: Date
14 | public let term: String
15 |
16 | public init(term: String, createdAt: Date = Date()) {
17 | self.createdAt = createdAt
18 | self.term = term
19 | }
20 |
21 | }
22 |
--------------------------------------------------------------------------------
/ChuckCore/UI Support/Util/ObservableType+Reachability.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ObservableType+Reachability.swift
3 | // Pods
4 | //
5 | // Created by Ivan Bruel on 22/03/2017.
6 | //
7 | //
8 |
9 | import Foundation
10 | import Reachability
11 | import RxCocoa
12 | import RxSwift
13 |
14 | public extension ObservableType {
15 |
16 | func retryOnConnect(timeout: TimeInterval) -> Observable {
17 | return retryWhen { _ in
18 | return Reachability.rx.isConnected
19 | .timeout(timeout, scheduler: MainScheduler.asyncInstance)
20 | }
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/ChuckCore/ChuckCore.h:
--------------------------------------------------------------------------------
1 | //
2 | // ChuckCore.h
3 | // ChuckCore
4 | //
5 | // Created by Guilherme Rambo on 22/05/18.
6 | // Copyright © 2018 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | #import
10 |
11 | //! Project version number for ChuckCore.
12 | FOUNDATION_EXPORT double ChuckCoreVersionNumber;
13 |
14 | //! Project version string for ChuckCore.
15 | FOUNDATION_EXPORT const unsigned char ChuckCoreVersionString[];
16 |
17 | // In this header, you should import all the public headers of your framework using statements like #import
18 |
19 |
20 |
--------------------------------------------------------------------------------
/Chuck/Definitions/Fonts.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Fonts.swift
3 | // Chuck
4 | //
5 | // Created by Guilherme Rambo on 27/05/18.
6 | // Copyright © 2018 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | extension UIFont {
12 | static let largeTitle = UIFont.systemFont(ofSize: 34, weight: .bold)
13 | static let info = UIFont.systemFont(ofSize: 16, weight: .medium)
14 | static let largeButton = UIFont.systemFont(ofSize: 18, weight: .medium)
15 | static let badge = UIFont.systemFont(ofSize: 14, weight: .medium)
16 | static let smallBadge = UIFont.systemFont(ofSize: 11, weight: .medium)
17 | }
18 |
--------------------------------------------------------------------------------
/ChuckCoreTests/Util/SearchResponse+Sample.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SearchResponse+Sample.swift
3 | // ChuckCoreTests
4 | //
5 | // Created by Guilherme Rambo on 27/05/18.
6 | // Copyright © 2018 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import XCTest
11 | @testable import ChuckCore
12 |
13 | extension SearchResponse {
14 |
15 | static func sample() throws -> SearchResponse {
16 | let data = try Bundle.chuckCore.fetch(resource: .search)
17 |
18 | let decoder = JSONDecoder()
19 | decoder.keyDecodingStrategy = .convertFromSnakeCase
20 | let response = try decoder.decode(SearchResponse.self, from: data)
21 |
22 | return response
23 | }
24 |
25 | }
26 |
--------------------------------------------------------------------------------
/Chuck/Controllers/Extensions/UIViewController+Children.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UIViewController+Children.swift
3 | // Chuck
4 | //
5 | // Created by Guilherme Rambo on 22/05/18.
6 | // Copyright © 2018 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | extension UIViewController {
12 |
13 | func installChild(_ viewController: UIViewController,
14 | withMask autoresizingMask: UIViewAutoresizing = [.flexibleWidth, .flexibleHeight])
15 | {
16 | addChildViewController(viewController)
17 | viewController.view.autoresizingMask = autoresizingMask
18 | view.addSubview(viewController.view)
19 | viewController.didMove(toParentViewController: self)
20 | }
21 |
22 | }
23 |
--------------------------------------------------------------------------------
/ChuckCore/UI Support/ViewModels/CategoryViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CategoryViewModel.swift
3 | // ChuckCore
4 | //
5 | // Created by Guilherme Rambo on 27/05/18.
6 | // Copyright © 2018 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import RxDataSources
11 |
12 | public struct CategoryViewModel: Equatable, IdentifiableType {
13 |
14 | public typealias Identity = String
15 |
16 | public var identity: String {
17 | return category.name
18 | }
19 |
20 | public let category: Category
21 |
22 | public init(category: Category) {
23 | self.category = category
24 | }
25 |
26 | public var name: String {
27 | return category.name.uppercased()
28 | }
29 |
30 | }
31 |
--------------------------------------------------------------------------------
/ChuckCore/UI Support/ViewModels/RecentSearchViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RecentSearchViewModel.swift
3 | // ChuckCore
4 | //
5 | // Created by Guilherme Rambo on 27/05/18.
6 | // Copyright © 2018 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import RxDataSources
11 |
12 | public struct RecentSearchViewModel: Equatable, IdentifiableType {
13 |
14 | public typealias Identity = String
15 |
16 | public var identity: String {
17 | return recentSearch.term
18 | }
19 |
20 | public let recentSearch: RecentSearch
21 |
22 | public init(with recentSearch: RecentSearch) {
23 | self.recentSearch = recentSearch
24 | }
25 |
26 | public var term: String {
27 | return recentSearch.term.uppercased()
28 | }
29 |
30 | }
31 |
--------------------------------------------------------------------------------
/ChuckCoreTests/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | BNDL
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleVersion
20 | 1
21 |
22 |
23 |
--------------------------------------------------------------------------------
/ChuckUITests/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | BNDL
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleVersion
20 | 1
21 |
22 |
23 |
--------------------------------------------------------------------------------
/Chuck/Definitions/Metrics.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Metrics.swift
3 | // Chuck
4 | //
5 | // Created by Guilherme Rambo on 27/05/18.
6 | // Copyright © 2018 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | struct Metrics {
12 | static let badgeHeight: CGFloat = 24
13 | static let smallBadgeHeight: CGFloat = 19
14 | static let extraPadding: CGFloat = 22
15 | static let headerHeight: CGFloat = 88
16 | static let badgeCornerRadius: CGFloat = 4
17 | static let buttonCornerRadius: CGFloat = 12
18 | static let cardCornerRadius: CGFloat = 8
19 | static let padding: CGFloat = 16
20 | static let largeButtonWidth: CGFloat = 317
21 | static let largeButtonHeight: CGFloat = 47
22 | static let searchBarRadius: CGFloat = 10
23 | static let searchBarHeight: CGFloat = 36
24 | }
25 |
--------------------------------------------------------------------------------
/ChuckCore/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | FMWK
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleVersion
20 | $(CURRENT_PROJECT_VERSION)
21 | NSPrincipalClass
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/ChuckCore/TestUtilities/UITestingLabel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UITestingLabel.swift
3 | // ChuckCore
4 | //
5 | // Created by Guilherme Rambo on 28/05/18.
6 | // Copyright © 2018 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | public enum UITestingLabel: String {
12 | case jokeCell
13 | case jokesList
14 | case loadingIndicator
15 | case searchButton
16 | case emptyView
17 | case searchView
18 | case searchBar
19 | case categoryBadge
20 | case recentSearchBadge
21 | }
22 |
23 | extension UIResponder {
24 |
25 | public var uiTestingLabel: UITestingLabel? {
26 | get {
27 | guard let label = accessibilityLabel else { return nil }
28 |
29 | return UITestingLabel(rawValue: label)
30 | }
31 | set {
32 | accessibilityLabel = newValue?.rawValue
33 | }
34 | }
35 |
36 | }
37 |
--------------------------------------------------------------------------------
/Chuck/Definitions/Colors.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Colors.swift
3 | // Chuck
4 | //
5 | // Created by Guilherme Rambo on 27/05/18.
6 | // Copyright © 2018 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | extension UIColor {
12 | static let primaryText: UIColor = .black
13 | static let badgeBackground: UIColor = .black
14 | static let badgeText: UIColor = .white
15 | static let infoText = UIColor(white: 63.0 / 255.0, alpha: 1.0)
16 | static let error = UIColor(red: 240.0 / 255.0, green: 69.0 / 255.0, blue: 10.0 / 255.0, alpha: 1.0)
17 | static let primary = UIColor(red: 0.0, green: 118.0 / 255.0, blue: 1.0, alpha: 1.0)
18 | static let buttonBackground = UIColor(red: 0.0, green: 106.0 / 255.0, blue: 230.0 / 255.0, alpha: 1.0)
19 | static let buttonText: UIColor = .white
20 | static let vibrantBadgeBackground = UIColor.white.withAlphaComponent(0.2)
21 | }
22 |
--------------------------------------------------------------------------------
/Chuck/Views/SelfSizingCollectionView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SelfSizingCollectionView.swift
3 | // Chuck
4 | //
5 | // Created by Guilherme Rambo on 28/05/18.
6 | // Copyright © 2018 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | final class SelfSizingCollectionView: UICollectionView {
12 |
13 | override func didMoveToSuperview() {
14 | super.didMoveToSuperview()
15 |
16 | startObservingContentSizeIfNeeded()
17 | }
18 |
19 | private func startObservingContentSizeIfNeeded() {
20 | guard superview != nil else { return }
21 | guard let badgeLayout = collectionViewLayout as? BadgeFlowLayout else { return }
22 |
23 | badgeLayout.didInvalidateLayout = { [weak self] _ in
24 | self?.invalidateIntrinsicContentSize()
25 | }
26 | }
27 |
28 | override var intrinsicContentSize: CGSize {
29 | return CGSize(width: UIViewNoIntrinsicMetric, height: collectionViewLayout.collectionViewContentSize.height)
30 | }
31 |
32 | }
33 |
--------------------------------------------------------------------------------
/Chuck/Definitions/Messages.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Messages.swift
3 | // Chuck
4 | //
5 | // Created by Guilherme Rambo on 28/05/18.
6 | // Copyright © 2018 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | struct Messages {
12 | static let firstLaunchEmtpy = """
13 | It looks like you haven't seen any
14 | facts yet, which is unfortunate.
15 |
16 | Start by searching for some! If you just want to see a random fact, shake your device.
17 | """
18 |
19 | static let searchResultsEmtpy = "Oops, I couldn't find any facts with that search term."
20 |
21 | static let searchResultsEmtpyOffline = """
22 | It looks like you don't have any facts with that search term on your library.
23 |
24 | Connect to the internet to search for new facts from the cloud.
25 | """
26 | }
27 |
--------------------------------------------------------------------------------
/TodayExtension/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleDisplayName
8 | Chuck
9 | CFBundleExecutable
10 | $(EXECUTABLE_NAME)
11 | CFBundleIdentifier
12 | $(PRODUCT_BUNDLE_IDENTIFIER)
13 | CFBundleInfoDictionaryVersion
14 | 6.0
15 | CFBundleName
16 | $(PRODUCT_NAME)
17 | CFBundlePackageType
18 | XPC!
19 | CFBundleShortVersionString
20 | 1.0
21 | CFBundleVersion
22 | 1
23 | NSExtension
24 |
25 | NSExtensionMainStoryboard
26 | MainInterface
27 | NSExtensionPointIdentifier
28 | com.apple.widget-extension
29 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/ChuckCore/Models/API/Joke.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Joke.swift
3 | // ChuckCore
4 | //
5 | // Created by Guilherme Rambo on 22/05/18.
6 | // Copyright © 2018 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public struct Joke: Codable, Equatable {
12 | public let id: String
13 | public let iconUrl: URL
14 | public let categories: [Category]
15 | public let url: URL
16 | public let value: String
17 |
18 | public enum CodingKeys: String, CodingKey {
19 | case id, iconUrl, url, value
20 | case categories = "category"
21 | }
22 |
23 | public init(from decoder: Decoder) throws {
24 | let container = try decoder.container(keyedBy: CodingKeys.self)
25 |
26 | id = try container.decode(String.self, forKey: .id)
27 | iconUrl = try container.decode(URL.self, forKey: .iconUrl)
28 | url = try container.decode(URL.self, forKey: .url)
29 | value = try container.decode(String.self, forKey: .value)
30 |
31 | let decodedCategories = try? container.decode([Category].self, forKey: .categories)
32 | categories = decodedCategories ?? []
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/ChuckCoreTests/ChuckCoreViewModelTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ChuckCoreViewModelTests.swift
3 | // ChuckCoreTests
4 | //
5 | // Created by Guilherme Rambo on 27/05/18.
6 | // Copyright © 2018 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import XCTest
11 | @testable import ChuckCore
12 |
13 | class ChuckCoreViewModelTests: XCTestCase {
14 |
15 | func testViewModelWithLargeMetrics() throws {
16 | let joke = try Joke.sample()
17 | let viewModel = JokeViewModel(joke: joke)
18 |
19 | XCTAssertEqual(viewModel.preferredMetrics, .large)
20 | XCTAssertEqual(viewModel.categoryName, "POLITICAL")
21 | XCTAssertEqual(viewModel.id, "uanj8roxsrwq6pgb0kufia")
22 | XCTAssertEqual(viewModel.body, "Guantuanamo Bay, Cuba, is the military code-word for \"Chuck Norris\' basement\".")
23 | }
24 |
25 | func testViewModelWithNoCategory() throws {
26 | let response = try SearchResponse.sample()
27 | let joke = response.result.first!
28 |
29 | let viewModel = JokeViewModel(joke: joke)
30 | XCTAssertEqual(viewModel.preferredMetrics, .regular)
31 | XCTAssertEqual(viewModel.categoryName, "UNCATEGORIZED")
32 | }
33 |
34 | }
35 |
--------------------------------------------------------------------------------
/ChuckCoreTests/Util/TestBundle.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TestBundle.swift
3 | // ChuckCoreTests
4 | //
5 | // Created by Guilherme Rambo on 22/05/18.
6 | // Copyright © 2018 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | private final class TestBundleInitializer { }
12 |
13 | enum TestResource: String {
14 | case categories
15 | case randomWithCategory
16 | case random
17 | case search
18 | case searchEmpty
19 |
20 | static var all: [TestResource] = [.categories, .randomWithCategory, .random, .search, .searchEmpty]
21 | }
22 |
23 | enum TestResourceError: Error {
24 | case notFound(String)
25 |
26 | var localizedDescription: String {
27 | switch self {
28 | case .notFound(let name):
29 | return "A required resource for ChuckCoreTests is not present in the bundle: \(name)"
30 | }
31 | }
32 | }
33 |
34 | extension Bundle {
35 |
36 | func fetch(resource: TestResource) throws -> Data {
37 | guard let url = Bundle.chuckCore.url(forResource: resource.rawValue, withExtension: "json") else {
38 | throw TestResourceError.notFound(resource.rawValue)
39 | }
40 |
41 | return try Data(contentsOf: url)
42 | }
43 |
44 | }
45 |
--------------------------------------------------------------------------------
/ChuckIntents/TellJokeIntentHandler.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TellJokeIntentHandler.swift
3 | // ChuckIntents
4 | //
5 | // Created by Guilherme Rambo on 23/04/19.
6 | // Copyright © 2019 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import RxSwift
11 | import ChuckCore
12 | import os.log
13 |
14 | final class TellJokeIntentHandler: NSObject, TellJokeIntentHandling {
15 |
16 | private let log = OSLog(subsystem: "ChuckIntents", category: "TellJokeIntentHandler")
17 |
18 | private let uiStack = ChuckUIStack()
19 |
20 | private let bag = DisposeBag()
21 |
22 | func handle(intent: TellJokeIntent, completion: @escaping (TellJokeIntentResponse) -> Void) {
23 | os_log("%{public}@", log: log, type: .debug, #function)
24 |
25 | uiStack.syncEngine.randomJoke().observeOn(MainScheduler.instance).bind { [weak self] joke in
26 | guard let self = self else { return }
27 |
28 | os_log("Fetched joke: %{public}@", log: self.log, type: .default, String(describing: joke))
29 |
30 | let response = TellJokeIntentResponse(code: .success, userActivity: nil)
31 |
32 | response.body = joke.body
33 |
34 | completion(response)
35 | }.disposed(by: bag)
36 | }
37 |
38 | }
39 |
--------------------------------------------------------------------------------
/TellJokeIntentsUI/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleDisplayName
8 | Chuck
9 | CFBundleExecutable
10 | $(EXECUTABLE_NAME)
11 | CFBundleIdentifier
12 | $(PRODUCT_BUNDLE_IDENTIFIER)
13 | CFBundleInfoDictionaryVersion
14 | 6.0
15 | CFBundleName
16 | $(PRODUCT_NAME)
17 | CFBundlePackageType
18 | XPC!
19 | CFBundleShortVersionString
20 | 1.0
21 | CFBundleVersion
22 | 1
23 | NSExtension
24 |
25 | NSExtensionAttributes
26 |
27 | IntentsSupported
28 |
29 | TellJokeIntent
30 |
31 |
32 | NSExtensionMainStoryboard
33 | MainInterface
34 | NSExtensionPointIdentifier
35 | com.apple.intents-ui-service
36 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/ChuckCoreTests/Util/Joke+Sample.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Joke+Sample.swift
3 | // ChuckCoreTests
4 | //
5 | // Created by Guilherme Rambo on 27/05/18.
6 | // Copyright © 2018 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import XCTest
11 | @testable import ChuckCore
12 |
13 | extension Joke {
14 |
15 | static func sample() throws -> Joke {
16 | let data = try Bundle.chuckCore.fetch(resource: .randomWithCategory)
17 |
18 | let decoder = JSONDecoder()
19 | decoder.keyDecodingStrategy = .convertFromSnakeCase
20 | let response = try decoder.decode(Joke.self, from: data)
21 |
22 | return response
23 | }
24 |
25 | }
26 |
27 | extension XCTestCase {
28 |
29 | func validateSampleJokeFields(with joke: Joke) {
30 | XCTAssert(joke.categories.count == 1)
31 | XCTAssert(joke.categories.first?.name == "political")
32 | XCTAssert(joke.id == "uanj8roxsrwq6pgb0kufia")
33 | XCTAssert(joke.value == "Guantuanamo Bay, Cuba, is the military code-word for \"Chuck Norris\' basement\".")
34 | XCTAssert(joke.url.absoluteString == "https://api.chucknorris.io/jokes/uanj8roxsrwq6pgb0kufia")
35 | XCTAssert(joke.iconUrl.absoluteString == "https://assets.chucknorris.host/img/avatar/chuck-norris.png")
36 | }
37 |
38 | }
39 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2018 Guilherme Rambo
2 |
3 | Redistribution and use in source and binary forms, with or without
4 | modification, are permitted provided that the following conditions are met:
5 |
6 | - Redistributions of source code must retain the above copyright notice, this
7 | list of conditions and the following disclaimer.
8 |
9 | - Redistributions in binary form must reproduce the above copyright notice,
10 | this list of conditions and the following disclaimer in the documentation
11 | and/or other materials provided with the distribution.
12 |
13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
14 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
15 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
16 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
17 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
18 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
19 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
20 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
21 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
22 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--------------------------------------------------------------------------------
/ChuckCore/Persistence/NSManagedObjectContext+.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NSManagedObjectContext+.swift
3 | // ChuckCore
4 | //
5 | // Created by Guilherme Rambo on 27/05/18.
6 | // Copyright © 2018 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import CoreData
11 | import RxDataSources
12 | import RxCoreData
13 | import RxSwift
14 |
15 | public extension Reactive where Base: NSManagedObjectContext {
16 |
17 | func create(_ type: E.Type = E.self) -> E.T {
18 | return NSEntityDescription.insertNewObject(forEntityName: E.entityName, into: self.base) as! E.T
19 | }
20 |
21 | func get(_ persistable: P) throws -> P.T? {
22 | let fetchRequest: NSFetchRequest = NSFetchRequest(entityName: P.entityName)
23 | fetchRequest.predicate = NSPredicate(format: "%K = %@", P.primaryAttributeName, persistable.identity)
24 |
25 | let result = (try self.base.execute(fetchRequest)) as! NSAsynchronousFetchResult
26 |
27 | return result.finalResult?.first
28 | }
29 |
30 | func getOrCreateEntity(for persistable: K) -> K.T {
31 | if let reusedEntity = try? self.get(persistable), reusedEntity != nil {
32 | return reusedEntity!
33 | } else {
34 | return self.create(K.self)
35 | }
36 | }
37 |
38 | }
39 |
--------------------------------------------------------------------------------
/ChuckCore/Persistence/Category+Persistence.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Category+Persistence.swift
3 | // ChuckCore
4 | //
5 | // Created by Guilherme Rambo on 27/05/18.
6 | // Copyright © 2018 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import CoreData
11 | import RxDataSources
12 | import RxCoreData
13 | import os.log
14 |
15 | extension Category: IdentifiableType {
16 |
17 | public typealias Identity = String
18 |
19 | public var identity: String {
20 | return name
21 | }
22 |
23 | }
24 |
25 | extension Category: Persistable {
26 |
27 | public typealias T = NSManagedObject
28 |
29 | public static var entityName: String {
30 | return "Category"
31 | }
32 |
33 | public static var primaryAttributeName: String {
34 | return "name"
35 | }
36 |
37 | public init(entity: T) {
38 | name = entity.value(forKey: "name") as! String
39 | }
40 |
41 | public func update(_ entity: T) {
42 | entity.setValue(name, forKey: "name")
43 |
44 | do {
45 | try entity.managedObjectContext?.save()
46 | } catch {
47 | os_log(
48 | "Failed to commit changes to MOC: %{public}@",
49 | log: .default,
50 | type: .fault,
51 | String(describing: error)
52 | )
53 | }
54 | }
55 |
56 | }
57 |
--------------------------------------------------------------------------------
/ChuckCoreTests/Util/NSPersistentContainer+Test.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NSPersistentContainer+Test.swift
3 | // ChuckCoreTests
4 | //
5 | // Created by Guilherme Rambo on 27/05/18.
6 | // Copyright © 2018 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import CoreData
11 | @testable import ChuckCore
12 |
13 | extension NSPersistentContainer {
14 |
15 | static var test: NSPersistentContainer {
16 | guard let url = Bundle.chuckCore.url(forResource: "Model", withExtension: "momd") else {
17 | fatalError("Failed to find Model.momd in ChuckCore")
18 | }
19 |
20 | guard let model = NSManagedObjectModel(contentsOf: url) else {
21 | fatalError("Failed to load managed object model from ChuckCore")
22 | }
23 |
24 | let persistentContainer = NSPersistentContainer(name: "Model", managedObjectModel: model)
25 |
26 | let description = NSPersistentStoreDescription()
27 | description.type = NSInMemoryStoreType
28 |
29 | persistentContainer.persistentStoreDescriptions = [description]
30 |
31 | persistentContainer.loadPersistentStores(completionHandler: { _, error in
32 | if let error = error {
33 | fatalError("Error loading persistent stores: \(error.localizedDescription)")
34 | }
35 | })
36 |
37 | return persistentContainer
38 | }
39 |
40 | }
41 |
--------------------------------------------------------------------------------
/ChuckIntents/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleDisplayName
8 | Chuck
9 | CFBundleExecutable
10 | $(EXECUTABLE_NAME)
11 | CFBundleIdentifier
12 | $(PRODUCT_BUNDLE_IDENTIFIER)
13 | CFBundleInfoDictionaryVersion
14 | 6.0
15 | CFBundleName
16 | $(PRODUCT_NAME)
17 | CFBundlePackageType
18 | XPC!
19 | CFBundleShortVersionString
20 | 1.0
21 | CFBundleVersion
22 | 1
23 | NSExtension
24 |
25 | NSExtensionAttributes
26 |
27 | IntentsRestrictedWhileLocked
28 |
29 | IntentsRestrictedWhileProtectedDataUnavailable
30 |
31 | IntentsSupported
32 |
33 | TellJokeIntent
34 |
35 |
36 | NSExtensionPointIdentifier
37 | com.apple.intents-service
38 | NSExtensionPrincipalClass
39 | $(PRODUCT_MODULE_NAME).IntentHandler
40 |
41 |
42 |
43 |
--------------------------------------------------------------------------------
/ChuckCore/Networking/ChuckAPIEnvironment.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ChuckAPIEnvironment.swift
3 | // ChuckCore
4 | //
5 | // Created by Guilherme Rambo on 27/05/18.
6 | // Copyright © 2018 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import os.log
11 |
12 | public struct ChuckAPIEnvironment {
13 | typealias EndpointResolver = (ChuckAPIEnvironment, String, [URLQueryItem]) -> URLRequest?
14 |
15 | let baseComponents: URLComponents
16 | let resolver: EndpointResolver
17 |
18 | public static let production = ChuckAPIEnvironment(
19 | baseComponents: URLComponents(string: "https://api.chucknorris.io")!,
20 | resolver: ChuckAPIEnvironment.standardResolver
21 | )
22 |
23 | func resolve(path: String, query: [URLQueryItem]) -> URLRequest? {
24 | return resolver(self, path, query)
25 | }
26 |
27 | static func standardResolver(environment: ChuckAPIEnvironment, path: String, query: [URLQueryItem]) -> URLRequest? {
28 | var components = environment.baseComponents
29 |
30 | components.path = path
31 | components.queryItems = query
32 |
33 | guard let url = components.url else {
34 | os_log(
35 | "Failed to generate URL for endpoint %@",
36 | log: .default,
37 | type: .error,
38 | String(describing: self)
39 | )
40 | return nil
41 | }
42 |
43 | return URLRequest(url: url)
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/ChuckCore/UI Support/Util/Collection+Shuffle.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Collection+Shuffle.swift
3 | // ChuckCore
4 | //
5 | // Created by Guilherme Rambo on 27/05/18.
6 | // Copyright © 2018 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | // Shuffle implementation courtesy of https://stackoverflow.com/questions/24026510/how-do-i-shuffle-an-array-in-swift
12 |
13 | public extension MutableCollection {
14 |
15 | /// Shuffles the contents of this collection.
16 | mutating func shuffle() {
17 | let c = count
18 | guard c > 1 else { return }
19 |
20 | for (firstUnshuffled, unshuffledCount) in zip(indices, stride(from: c, to: 1, by: -1)) {
21 | // Change `Int` in the next line to `IndexDistance` in < Swift 4.1
22 | let d: Int = numericCast(arc4random_uniform(numericCast(unshuffledCount)))
23 | let i = index(firstUnshuffled, offsetBy: d)
24 | swapAt(firstUnshuffled, i)
25 | }
26 | }
27 |
28 | }
29 |
30 | public extension Sequence {
31 |
32 | /// Returns an array with the contents of this sequence, shuffled.
33 | func shuffled() -> [Element] {
34 | var result = Array(self)
35 | result.shuffle()
36 | return result
37 | }
38 |
39 | func randomSelection(with amount: Int) -> [Element] {
40 | let effectiveCount = Swift.min(amount, underestimatedCount)
41 | let randomSlice = shuffled()[0..
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | APPL
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleVersion
20 | 1
21 | LSRequiresIPhoneOS
22 |
23 | NSUserActivityTypes
24 |
25 | TellJokeIntent
26 |
27 | UILaunchStoryboardName
28 | LaunchScreen
29 | UIRequiredDeviceCapabilities
30 |
31 | armv7
32 |
33 | UISupportedInterfaceOrientations
34 |
35 | UIInterfaceOrientationPortrait
36 |
37 | UISupportedInterfaceOrientations~ipad
38 |
39 | UIInterfaceOrientationPortrait
40 | UIInterfaceOrientationPortraitUpsideDown
41 | UIInterfaceOrientationLandscapeLeft
42 | UIInterfaceOrientationLandscapeRight
43 |
44 |
45 |
46 |
--------------------------------------------------------------------------------
/ChuckCoreTests/ChuckCoreAPIEndpointTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ChuckCoreAPIEndpointTests.swift
3 | // ChuckCoreTests
4 | //
5 | // Created by Guilherme Rambo on 27/05/18.
6 | // Copyright © 2018 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import XCTest
11 | @testable import ChuckCore
12 |
13 | class ChuckCoreAPIEndpointTests: XCTestCase {
14 |
15 | func testRandomEndpointRequestGeneration() {
16 | let request = ChuckAPIEndpoint.random().request
17 | XCTAssertEqual(request?.url?.absoluteString, "https://api.chucknorris.io/jokes/random?")
18 | }
19 |
20 | func testRandomWithCategoryRequestGeneration() {
21 | let request = ChuckAPIEndpoint.random(with: "dev").request
22 | XCTAssertEqual(request?.url?.absoluteString, "https://api.chucknorris.io/jokes/random?category=dev")
23 | }
24 |
25 | func testCategoriesRequestGeneration() {
26 | let request = ChuckAPIEndpoint<[ChuckCore.Category]>.categories().request
27 | XCTAssertEqual(request?.url?.absoluteString, "https://api.chucknorris.io/jokes/categories?")
28 | }
29 |
30 | func testSimpleSearchRequestGeneration() {
31 | let request = ChuckAPIEndpoint.search(with: "iPhone").request
32 | XCTAssertEqual(request?.url?.absoluteString, "https://api.chucknorris.io/jokes/search?query=iPhone")
33 | }
34 |
35 | func testSearchWithSpecialCharactersRequestGeneration() {
36 | let request = ChuckAPIEndpoint.search(with: "Caffè Macs").request
37 | XCTAssertEqual(request?.url?.absoluteString, "https://api.chucknorris.io/jokes/search?query=Caff%C3%A8%20Macs")
38 | }
39 |
40 | }
41 |
--------------------------------------------------------------------------------
/Chuck/Views/BadgeFlowLayout.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BadgeFlowLayout.swift
3 | // Chuck
4 | //
5 | // Created by Guilherme Rambo on 28/05/18.
6 | // Copyright © 2018 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | final class BadgeFlowLayout: UICollectionViewFlowLayout {
12 |
13 | var didInvalidateLayout: ((CGSize) -> Void)?
14 |
15 | override func invalidateLayout() {
16 | super.invalidateLayout()
17 |
18 | guard collectionViewContentSize.height > 0 else { return }
19 |
20 | noteLayoutInvalidation()
21 | }
22 |
23 | private func noteLayoutInvalidation() {
24 | NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(doNoteLayoutInvalidation), object: nil)
25 | perform(#selector(doNoteLayoutInvalidation), with: nil, afterDelay: 0)
26 | }
27 |
28 | @objc private func doNoteLayoutInvalidation() {
29 | didInvalidateLayout?(collectionViewContentSize)
30 | }
31 |
32 | override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
33 | let attributes = super.layoutAttributesForElements(in: rect)
34 |
35 | var leftMargin = sectionInset.left
36 | var maxY: CGFloat = -1.0
37 |
38 | attributes?.forEach { layoutAttribute in
39 | if layoutAttribute.frame.origin.y >= maxY {
40 | leftMargin = sectionInset.left
41 | }
42 |
43 | layoutAttribute.frame.origin.x = leftMargin
44 |
45 | leftMargin += layoutAttribute.frame.width + minimumInteritemSpacing
46 | maxY = max(layoutAttribute.frame.maxY , maxY)
47 | }
48 |
49 | return attributes
50 | }
51 |
52 | }
53 |
--------------------------------------------------------------------------------
/ChuckCore/UI Support/ViewModels/JokeViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // JokeViewModel.swift
3 | // ChuckCore
4 | //
5 | // Created by Guilherme Rambo on 27/05/18.
6 | // Copyright © 2018 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import RxDataSources
11 |
12 | public struct JokeViewModel: Equatable, IdentifiableType {
13 |
14 | public typealias Identity = String
15 |
16 | public var identity: String {
17 | return id
18 | }
19 |
20 | public struct Metrics: Equatable {
21 | public let fontSize: CGFloat
22 | public let fontWeight: UIFont.Weight
23 |
24 | public static let large = Metrics(fontSize: 28, fontWeight: .semibold)
25 | public static let regular = Metrics(fontSize: 16, fontWeight: .regular)
26 | }
27 |
28 | public let joke: Joke
29 | public let categoryViewModel: CategoryViewModel?
30 |
31 | public init(joke: Joke) {
32 | self.joke = joke
33 |
34 | if let firstCategory = joke.categories.first {
35 | categoryViewModel = CategoryViewModel(category: firstCategory)
36 | } else {
37 | categoryViewModel = nil
38 | }
39 | }
40 |
41 | public var id: String {
42 | return joke.id
43 | }
44 |
45 | public var body: String {
46 | return joke.value
47 | }
48 |
49 | public var categoryName: String {
50 | return categoryViewModel?.name ?? "UNCATEGORIZED"
51 | }
52 |
53 | public var url: URL {
54 | return joke.url
55 | }
56 |
57 | public var preferredMetrics: Metrics {
58 | switch body.count {
59 | case 0...80:
60 | return .large
61 | default:
62 | return .regular
63 | }
64 | }
65 |
66 | }
67 |
--------------------------------------------------------------------------------
/Chuck/Resources/Base.lproj/LaunchScreen.storyboard:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/ChuckCore/Persistence/Model.xcdatamodeld/Model.xcdatamodel/contents:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/Chuck/Views/BadgeCollectionViewCell.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BadgeCollectionViewCell.swift
3 | // Chuck
4 | //
5 | // Created by Guilherme Rambo on 28/05/18.
6 | // Copyright © 2018 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | class BadgeCollectionViewCell: UICollectionViewCell {
12 |
13 | var action: ((String) -> Void)?
14 |
15 | var title: String? {
16 | get {
17 | return badgeView.title
18 | }
19 | set {
20 | badgeView.title = newValue
21 | }
22 | }
23 |
24 | private lazy var badgeView: BadgeView = {
25 | let badge = BadgeView()
26 |
27 | badge.translatesAutoresizingMaskIntoConstraints = false
28 | badge.backgroundColor = .vibrantBadgeBackground
29 | badge.addTarget(self, action: #selector(badgeTapped), for: .touchUpInside)
30 |
31 | return badge
32 | }()
33 |
34 | override init(frame: CGRect) {
35 | super.init(frame: frame)
36 |
37 | setup()
38 | }
39 |
40 | required init?(coder aDecoder: NSCoder) {
41 | super.init(coder: aDecoder)
42 |
43 | setup()
44 | }
45 |
46 | private func setup() {
47 | contentView.addSubview(badgeView)
48 | badgeView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor).isActive = true
49 | badgeView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor).isActive = true
50 | badgeView.topAnchor.constraint(equalTo: contentView.topAnchor).isActive = true
51 | badgeView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor).isActive = true
52 | }
53 |
54 | override var intrinsicContentSize: CGSize {
55 | return badgeView.systemLayoutSizeFitting(UILayoutFittingCompressedSize)
56 | }
57 |
58 | @objc private func badgeTapped() {
59 | guard let title = title else { return }
60 |
61 | action?(title)
62 | }
63 |
64 | }
65 |
--------------------------------------------------------------------------------
/ChuckCore/TestUtilities/ChuckAPIEnvironment+Test.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ChuckAPIEnvironment+Test.swift
3 | // ChuckCoreTests
4 | //
5 | // Created by Guilherme Rambo on 27/05/18.
6 | // Copyright © 2018 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | extension ChuckAPIEnvironment {
12 |
13 | /// The testing environment uses assets in the bundle's resources path to mimick API responses
14 | public static let test: ChuckAPIEnvironment = {
15 | let resolver: ChuckAPIEnvironment.EndpointResolver = { environment, path, query in
16 | var mockFilename: String?
17 |
18 | switch path {
19 | case let path where path.contains("search"):
20 | mockFilename = query.contains(where: { $0.value == "empty" }) ? "searchEmpty" : "search"
21 | case let path where path.contains("categories"):
22 | mockFilename = "categories"
23 | case let path where path.contains("random"):
24 | mockFilename = query.contains(where: { $0.name == "category" }) ? "randomWithCategory" : "random"
25 | default:
26 | break
27 | }
28 |
29 | guard let filename = mockFilename else {
30 | fatalError("Invalid path for test environment: \(path)")
31 | }
32 |
33 | guard let url = environment.baseComponents.url?.appendingPathComponent(filename).appendingPathExtension("json") else {
34 | fatalError("Failed to generate URL for testing environment")
35 | }
36 |
37 | return URLRequest(url: url)
38 | }
39 |
40 | guard let baseURL = Bundle.chuckCore.resourceURL else {
41 | fatalError("Failed to get resource URL for ChuckCoreTests bundle which is required for the test to run")
42 | }
43 |
44 | return ChuckAPIEnvironment(baseComponents: URLComponents(url: baseURL, resolvingAgainstBaseURL: false)!, resolver: resolver)
45 | }()
46 |
47 | }
48 |
--------------------------------------------------------------------------------
/TodayExtension/Base.lproj/MainInterface.storyboard:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/TellJokeIntentsUI/IntentViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // IntentViewController.swift
3 | // TellJokeIntentsUI
4 | //
5 | // Created by Guilherme Rambo on 23/04/19.
6 | // Copyright © 2019 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import IntentsUI
10 | import ChuckCore
11 | import NotificationCenter
12 | import os.log
13 |
14 | class IntentViewController: UIViewController, INUIHostedViewControlling {
15 |
16 | private let log = OSLog(subsystem: "ChuckIntents", category: "IntentViewController")
17 |
18 | @IBOutlet weak var vfxView: UIVisualEffectView!
19 | @IBOutlet weak var label: UILabel!
20 |
21 | override func viewDidLoad() {
22 | super.viewDidLoad()
23 |
24 | view.backgroundColor = .clear
25 | vfxView.effect = UIVibrancyEffect.widgetPrimary()
26 | }
27 |
28 | // MARK: - INUIHostedViewControlling
29 |
30 | // Prepare your view controller for the interaction to handle.
31 | func configureView(for parameters: Set, of interaction: INInteraction, interactiveBehavior: INUIInteractiveBehavior, context: INUIHostedViewContext, completion: @escaping (Bool, Set, CGSize) -> Void) {
32 | guard let response = interaction.intentResponse as? TellJokeIntentResponse else {
33 | os_log("Failed to get response as TellJokeIntentResponse!", log: self.log, type: .fault)
34 | completion(false, parameters, self.desiredSize)
35 | return
36 | }
37 |
38 | os_log("Got response %@", log: self.log, type: .default, String(describing: response))
39 |
40 | label.text = response.body
41 | view.frame = CGRect(origin: .zero, size: desiredSize)
42 |
43 | completion(true, parameters, self.desiredSize)
44 | }
45 |
46 | var desiredSize: CGSize {
47 | let maxSize = self.extensionContext!.hostedViewMaximumAllowedSize
48 |
49 | let layoutSize = view.systemLayoutSizeFitting(UIView.layoutFittingExpandedSize)
50 |
51 | return CGSize(width: maxSize.width, height: layoutSize.height)
52 | }
53 |
54 | }
55 |
--------------------------------------------------------------------------------
/ChuckCore/UI Support/ChuckUIStack.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ChuckUIStack.swift
3 | // ChuckCore
4 | //
5 | // Created by Guilherme Rambo on 15/04/19.
6 | // Copyright © 2019 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import CoreData
11 | import Reachability
12 |
13 | public final class ChuckUIStack {
14 |
15 | public lazy var persistentContainer: NSPersistentContainer = {
16 | return self.makePersistentContainer()
17 | }()
18 |
19 | private func makePersistentContainer() -> NSPersistentContainer {
20 | guard let baseURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: UserDefaults.chuckApplicationGroup) else {
21 | fatalError("Failed to get container URL for app group identifier \(UserDefaults.chuckApplicationGroup)")
22 | }
23 |
24 | let storageURL = baseURL.appendingPathComponent("Storage.db")
25 |
26 | do {
27 | let container = try ChuckCorePersistentContainer(url: storageURL, readOnly: false)
28 |
29 | container.loadPersistentStores(completionHandler: { _, error in
30 | if let error = error {
31 | fatalError("Error loading persistent stores: \(error.localizedDescription)")
32 | }
33 | })
34 |
35 | return container
36 | } catch {
37 | fatalError("Storage initialization error: \(error)")
38 | }
39 | }
40 |
41 | public lazy var reachability: Reachability = {
42 | guard let instance = Reachability(hostname: "api.chucknorris.io") else {
43 | fatalError("Unable to instantiate reachability instance")
44 | }
45 |
46 | return instance
47 | }()
48 |
49 | public lazy var syncEngine: SyncEngine = {
50 | let env: ChuckAPIEnvironment = TestArguments.isRunningUITests ? .test : .production
51 |
52 | let client = ChuckAPIClient(environment: env)
53 |
54 | return SyncEngine(
55 | client: client,
56 | persistentContainer: persistentContainer,
57 | reachability: reachability
58 | )
59 | }()
60 |
61 | public init() {
62 |
63 | }
64 |
65 | }
66 |
--------------------------------------------------------------------------------
/README.MD:
--------------------------------------------------------------------------------
1 | # Chuck
2 |
3 | Browse [Chuck Norris Facts](https://api.chucknorris.io/) on your iPhone.
4 |
5 | ### Start by doing a search for your favorite keywords or using one of the suggested topics. To search, tap the search button or pull down on the main screen.
6 |
7 | 
8 |
9 | ### All facts are saved to your library and every time you launch the app, a different selection of random facts shows up, even when you're not on-line. You can also shake your device to fetch a fresh random fact from the cloud.
10 |
11 | 
12 |
13 | ### See a really cool fact you like? You can share it with your friends, just tap and hold or tap the share button.
14 |
15 | 
16 |
17 | # Nerdy bits 🤓
18 |
19 | The app is written in Swift. It uses the following 3rd party libraries:
20 |
21 | - [RxSwift](https://github.com/ReactiveX/RxSwift)
22 | - [RxDataSources](https://github.com/RxSwiftCommunity/RxDataSources)
23 | - [RxCoreData](RxCoreData)
24 | - [Reachability.swift](https://github.com/ashleymills/Reachability.swift)
25 |
26 | ## Architecture
27 |
28 | The app uses a mix of MVC and MVVM, view controller containment is used to avoid the "massive view controller problem", Rx is used for bindings and to handle all of the data pipeline where appropriate.
29 |
30 | The app is also offline-first, which means every fact downloaded from the API gets stored locally (in CoreData) so the user can browse previously seen facts when offline.
31 |
32 | The model, networking, storage and view model layers are tested. There are also UI tests for the main interactions.
33 |
34 | All core functionality is implemented in a separate framework, ChuckCore, which should make it easy to port this app to other platforms or create extensions in the future.
35 |
36 | ## Building
37 |
38 | Building the app requires [Carthage](https://github.com/Carthage/Carthage).
39 |
40 | Clone the repo and install the dependencies:
41 |
42 | ```
43 | git clone https://github.com/insidegui/Chuck.git
44 | cd Chuck
45 | carthage bootstrap --platform iOS
46 | ```
47 |
48 | You'll probably have to change the signing identity in the project settings to be able to build the app locally.
--------------------------------------------------------------------------------
/ChuckUITests/ChuckUITests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ChuckUITests.swift
3 | // ChuckUITests
4 | //
5 | // Created by Guilherme Rambo on 22/05/18.
6 | // Copyright © 2018 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import XCTest
10 | @testable import ChuckCore
11 |
12 | class ChuckUITests: XCTestCase {
13 |
14 | var app: XCUIApplication!
15 |
16 | override func setUp() {
17 | super.setUp()
18 |
19 | continueAfterFailure = false
20 |
21 | app = XCUIApplication()
22 |
23 | app.launchArguments.append("--uitests")
24 |
25 | app.launch()
26 | }
27 |
28 | override func tearDown() {
29 | super.tearDown()
30 | }
31 |
32 | func testEmptyViewIsDisplayedOnFirstLaunch() {
33 | XCTAssertTrue(app.isShowingEmptyScreen)
34 | }
35 |
36 | func testTappingSearchButtonShowsSearchUI() {
37 | app.buttons[UITestingLabel.searchButton.rawValue].tap()
38 | XCTAssertTrue(app.isShowingSearch)
39 | }
40 |
41 | func testSearchWithResultsShowsResultsOnList() {
42 | app.buttons[UITestingLabel.searchButton.rawValue].tap()
43 |
44 | let searchBar = app.otherElements[UITestingLabel.searchBar.rawValue]
45 | searchBar.tap()
46 | searchBar.typeText("iPhone\n")
47 |
48 | let element = app.otherElements[UITestingLabel.emptyView.rawValue]
49 | let predicate = NSPredicate(format: "isHittable == 0")
50 |
51 | expectation(for: predicate, evaluatedWith: element, handler: nil)
52 | waitForExpectations(timeout: 2, handler: nil)
53 | }
54 |
55 | func testSearchWithNoResultsShowsEmptyView() {
56 | app.buttons[UITestingLabel.searchButton.rawValue].tap()
57 |
58 | let searchBar = app.otherElements[UITestingLabel.searchBar.rawValue]
59 | searchBar.tap()
60 | searchBar.typeText("empty\n")
61 |
62 | XCTAssertTrue(app.otherElements[UITestingLabel.emptyView.rawValue].isHittable)
63 | }
64 |
65 | }
66 |
67 | extension XCUIApplication {
68 |
69 | var isShowingEmptyScreen: Bool {
70 | return otherElements[UITestingLabel.emptyView.rawValue].exists
71 | }
72 |
73 | var isShowingSearch: Bool {
74 | return otherElements[UITestingLabel.searchView.rawValue].exists
75 | }
76 |
77 | }
78 |
--------------------------------------------------------------------------------
/Chuck/Views/BadgeView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BadgeView.swift
3 | // Chuck
4 | //
5 | // Created by Guilherme Rambo on 27/05/18.
6 | // Copyright © 2018 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | class BadgeView: UIButton {
12 |
13 | enum Style: Int {
14 | case small
15 | case regular
16 | }
17 |
18 | var style: Style = .regular {
19 | didSet {
20 | guard style != oldValue else { return }
21 |
22 | updateStyle()
23 | }
24 | }
25 |
26 | override init(frame: CGRect) {
27 | super.init(frame: frame)
28 |
29 | setup()
30 | }
31 |
32 | required init?(coder aDecoder: NSCoder) {
33 | super.init(coder: aDecoder)
34 |
35 | setup()
36 | }
37 |
38 | var title: String? {
39 | get {
40 | return badgeTitleLabel.text
41 | }
42 | set {
43 | badgeTitleLabel.text = newValue
44 | }
45 | }
46 |
47 | private lazy var badgeTitleLabel: UILabel = {
48 | let label = UILabel()
49 |
50 | label.translatesAutoresizingMaskIntoConstraints = false
51 | label.font = .badge
52 | label.textColor = .badgeText
53 | label.textAlignment = .center
54 |
55 | return label
56 | }()
57 |
58 | private func setup() {
59 | backgroundColor = .badgeBackground
60 |
61 | addSubview(badgeTitleLabel)
62 | badgeTitleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Metrics.padding/2).isActive = true
63 | badgeTitleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Metrics.padding/2).isActive = true
64 | badgeTitleLabel.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true
65 |
66 | layer.cornerRadius = Metrics.badgeCornerRadius
67 | }
68 |
69 | private var heightForCurrentStyle: CGFloat {
70 | switch style {
71 | case .regular:
72 | return Metrics.badgeHeight
73 | case .small:
74 | return Metrics.smallBadgeHeight
75 | }
76 | }
77 |
78 | override var intrinsicContentSize: CGSize {
79 | return CGSize(width: UIViewNoIntrinsicMetric, height: heightForCurrentStyle)
80 | }
81 |
82 | private func updateStyle() {
83 | badgeTitleLabel.font = style == .regular ? .badge : .smallBadge
84 |
85 | invalidateIntrinsicContentSize()
86 | }
87 |
88 | }
89 |
--------------------------------------------------------------------------------
/ChuckCore/UI Support/Util/Reachability+Rx.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Reachability+Rx.swift
3 | // Unbabel
4 | //
5 | // Created by Ivan Bruel on 22/03/2017.
6 | // Copyright © 2017 Unbabel, Inc. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import Reachability
11 | import RxCocoa
12 | import RxSwift
13 |
14 | extension Reachability: ReactiveCompatible { }
15 |
16 | public extension Reactive where Base: Reachability {
17 |
18 | static var reachabilityChanged: Observable {
19 | return NotificationCenter.default.rx.notification(Notification.Name.reachabilityChanged)
20 | .flatMap { notification -> Observable in
21 | guard let reachability = notification.object as? Reachability else {
22 | return .empty()
23 | }
24 | return .just(reachability)
25 | }
26 | }
27 |
28 | static var status: Observable {
29 | return reachabilityChanged
30 | .map { $0.connection }
31 | }
32 |
33 | static var isReachable: Observable {
34 | return reachabilityChanged
35 | .map { $0.connection != .none }
36 | }
37 |
38 | static var isConnected: Observable {
39 | return isReachable
40 | .filter { $0 }
41 | .map { _ in Void() }
42 | }
43 |
44 | static var isDisconnected: Observable {
45 | return isReachable
46 | .filter { !$0 }
47 | .map { _ in Void() }
48 | }
49 | }
50 |
51 | public extension Reactive where Base: Reachability {
52 |
53 | var reachabilityChanged: Observable {
54 | return NotificationCenter.default.rx.notification(Notification.Name.reachabilityChanged, object: base)
55 | .flatMap { notification -> Observable in
56 | guard let reachability = notification.object as? Reachability else {
57 | return .empty()
58 | }
59 | return .just(reachability)
60 | }
61 | }
62 |
63 | var status: Observable {
64 | return reachabilityChanged
65 | .map { $0.connection }
66 | }
67 |
68 | var isReachable: Observable {
69 | return reachabilityChanged
70 | .map { $0.connection != .none }
71 | }
72 |
73 | var isConnected: Observable {
74 | return isReachable
75 | .filter { $0 }
76 | .map { _ in Void() }
77 | }
78 |
79 | var isDisconnected: Observable {
80 | return isReachable
81 | .filter { !$0 }
82 | .map { _ in Void() }
83 | }
84 | }
85 |
86 |
--------------------------------------------------------------------------------
/ChuckCore/Persistence/Joke+Persistence.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Joke+Persistence.swift
3 | // ChuckCore
4 | //
5 | // Created by Guilherme Rambo on 27/05/18.
6 | // Copyright © 2018 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import CoreData
11 | import RxDataSources
12 | import RxCoreData
13 | import os.log
14 |
15 | extension Joke: IdentifiableType {
16 |
17 | public typealias Identity = String
18 |
19 | public var identity: String {
20 | return id
21 | }
22 |
23 | }
24 |
25 | extension Joke: Persistable {
26 |
27 | public typealias T = NSManagedObject
28 |
29 | public static var entityName: String {
30 | return "Joke"
31 | }
32 |
33 | public static var primaryAttributeName: String {
34 | return "id"
35 | }
36 |
37 | public init(entity: T) {
38 | id = entity.value(forKey: CodingKeys.id.stringValue) as! String
39 | iconUrl = entity.value(forKey: CodingKeys.iconUrl.stringValue) as! URL
40 | url = entity.value(forKey: CodingKeys.url.stringValue) as! URL
41 | value = entity.value(forKey: CodingKeys.value.stringValue) as! String
42 |
43 | if let categoryEntities = entity.value(forKey: "categories") as? Set {
44 | categories = categoryEntities.map(Category.init)
45 | } else {
46 | categories = []
47 | }
48 | }
49 |
50 | public func update(_ entity: T) {
51 | entity.setValue(id, forKey: CodingKeys.id.stringValue)
52 | entity.setValue(iconUrl, forKey: CodingKeys.iconUrl.stringValue)
53 | entity.setValue(url, forKey: CodingKeys.url.stringValue)
54 | entity.setValue(value, forKey: CodingKeys.value.stringValue)
55 |
56 | let categoryEntities = categories.compactMap { category -> NSManagedObject? in
57 | guard let child = entity.managedObjectContext?.rx.getOrCreateEntity(for: category) else {
58 | return nil
59 | }
60 |
61 | category.update(child)
62 |
63 | return child
64 | }
65 |
66 | entity.setValue(Set(categoryEntities), forKey: "categories")
67 |
68 | do {
69 | try entity.managedObjectContext?.save()
70 | } catch {
71 | os_log(
72 | "Failed to commit changes to MOC: %{public}@",
73 | log: .default,
74 | type: .fault,
75 | String(describing: error)
76 | )
77 | }
78 | }
79 |
80 | }
81 |
--------------------------------------------------------------------------------
/ChuckCoreTests/ChuckCorePersistenceTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ChuckCorePersistenceTests.swift
3 | // ChuckCoreTests
4 | //
5 | // Created by Guilherme Rambo on 27/05/18.
6 | // Copyright © 2018 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import XCTest
10 | import Foundation
11 |
12 | import CoreData
13 | import RxCoreData
14 | import RxBlocking
15 |
16 | @testable import ChuckCore
17 |
18 | class ChuckCorePersistenceTests: XCTestCase {
19 |
20 | private var persistentContainer: NSPersistentContainer = .test
21 |
22 | private var moc: NSManagedObjectContext {
23 | return persistentContainer.viewContext
24 | }
25 |
26 | override func setUp() {
27 | persistentContainer = .test
28 | }
29 |
30 | /// Ensures the joke's properties are preserved after being persisted and restored from the database
31 | func testJokePersistenceRoundTrip() throws {
32 | let joke = try Joke.sample()
33 |
34 | try moc.rx.update(joke)
35 |
36 | let results = try moc.rx.entities(Joke.self).toBlocking().first()
37 |
38 | XCTAssertNotNil(results)
39 | XCTAssert(results?.count == 1)
40 |
41 | let jokeFromDB = results!.first!
42 |
43 | validateSampleJokeFields(with: jokeFromDB)
44 | }
45 |
46 | /// Ensures that recent searches are stored and retrieved properly
47 | func testRecentSearchPersistenceRoundTrip() throws {
48 | let referenceDate = Date()
49 | let recentSearch = RecentSearch(term: "Apple", createdAt: referenceDate)
50 |
51 | try moc.rx.update(recentSearch)
52 |
53 | let results = try moc.rx.entities(RecentSearch.self).toBlocking().first()
54 |
55 | XCTAssertNotNil(results)
56 | XCTAssert(results?.count == 1)
57 | XCTAssert(results?.first?.term == "Apple")
58 | XCTAssert(results?.first?.createdAt == referenceDate)
59 | }
60 |
61 | /// Ensures that searching for the same term repeatedly does not add a new entry to the database for the same search
62 | func testRecentSearchesAreNotDuplicated() throws {
63 | let search = RecentSearch(term: "Apple")
64 | let search2 = RecentSearch(term: "Microsoft")
65 | let search3 = RecentSearch(term: "Apple")
66 |
67 | try moc.rx.update(search)
68 | try moc.rx.update(search2)
69 | try moc.rx.update(search3)
70 |
71 | let results = try moc.rx.entities(RecentSearch.self).toBlocking().first()
72 |
73 | XCTAssertNotNil(results)
74 | XCTAssert(results?.count == 2)
75 | }
76 |
77 | }
78 |
--------------------------------------------------------------------------------
/ChuckCore/Networking/ChuckAPIEndpoint.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ChuckAPIEndpoint.swift
3 | // ChuckCore
4 | //
5 | // Created by Guilherme Rambo on 27/05/18.
6 | // Copyright © 2018 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import os.log
11 |
12 | struct ChuckAPIEndpoint {
13 |
14 | private let path: String
15 | private let query: [URLQueryItem]
16 | private let environment: ChuckAPIEnvironment
17 |
18 | private init(path: String, query: [URLQueryItem] = [], environment: ChuckAPIEnvironment = .production) {
19 | self.path = path
20 | self.query = query
21 | self.environment = environment
22 | }
23 |
24 | /// An endpoint appropriate for getting a random joke
25 | static func random(environment: ChuckAPIEnvironment = .production) -> ChuckAPIEndpoint {
26 | return ChuckAPIEndpoint(path: "/jokes/random", environment: environment)
27 | }
28 |
29 | /// An endpoint appropriate for getting a list of available categories
30 | static func categories(environment: ChuckAPIEnvironment = .production) -> ChuckAPIEndpoint<[Category]> {
31 | return ChuckAPIEndpoint<[Category]>(path: "/jokes/categories", environment: environment)
32 | }
33 |
34 | /// Random
35 | ///
36 | /// - Parameter category: the category to get the jokes for
37 | /// - Returns: An endpoint appropriate for getting jokes with the specified category
38 | static func random(with category: String, environment: ChuckAPIEnvironment = .production) -> ChuckAPIEndpoint {
39 | let query = [URLQueryItem(name: "category", value: category)]
40 |
41 | return ChuckAPIEndpoint(path: "/jokes/random", query: query, environment: environment)
42 | }
43 |
44 | /// Search
45 | ///
46 | /// - Parameter term: the term to search for
47 | /// - Returns: An endpoint appropriate for searching with the specified term
48 | static func search(with term: String, environment: ChuckAPIEnvironment = .production) -> ChuckAPIEndpoint {
49 | // If the sanitization fails, we use the original term. It think this is a good approach since
50 | // it will more than likely result in an HTTP request error that ends up bubbling up to the UI
51 | let query = [URLQueryItem(name: "query", value: term)]
52 |
53 | return ChuckAPIEndpoint(path: "/jokes/search", query: query, environment: environment)
54 | }
55 |
56 | var request: URLRequest? {
57 | return environment.resolve(path: path, query: query)
58 | }
59 |
60 | }
61 |
--------------------------------------------------------------------------------
/Chuck/Controllers/Search/Transition/SearchTransition.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SearchTransition.swift
3 | // Chuck
4 | //
5 | // Created by Guilherme Rambo on 28/05/18.
6 | // Copyright © 2018 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | class SearchTransition: UIPercentDrivenInteractiveTransition, UIViewControllerAnimatedTransitioning {
12 |
13 | var isDismissing = false
14 |
15 | var auxAnimations: (()-> Void)?
16 | var auxAnimationsCancel: (()-> Void)?
17 |
18 | var context: UIViewControllerContextTransitioning?
19 | var animator: UIViewPropertyAnimator?
20 |
21 | func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
22 | return 0.4
23 | }
24 |
25 | func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
26 | transitionAnimator(using: transitionContext).startAnimation()
27 | }
28 |
29 | func transitionAnimator(using transitionContext: UIViewControllerContextTransitioning) -> UIViewImplicitlyAnimating {
30 | let duration = transitionDuration(using: transitionContext)
31 |
32 | let animator = UIViewPropertyAnimator(duration: duration, curve: .easeOut)
33 |
34 | let container = transitionContext.containerView
35 |
36 | if !isDismissing {
37 | guard let to = transitionContext.view(forKey: .to) else { return animator }
38 |
39 | to.frame = container.bounds
40 | container.addSubview(to)
41 | }
42 |
43 | animator.addCompletion { position in
44 | switch position {
45 | case .end:
46 | transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
47 | default:
48 | transitionContext.completeTransition(false)
49 | self.auxAnimationsCancel?()
50 | }
51 | }
52 |
53 | if let auxAnimations = auxAnimations {
54 | animator.addAnimations(auxAnimations)
55 | }
56 |
57 | self.animator = animator
58 | self.context = transitionContext
59 |
60 | animator.addCompletion { [unowned self] _ in
61 | self.animator = nil
62 | self.context = nil
63 | }
64 | animator.isUserInteractionEnabled = true
65 |
66 | return animator
67 | }
68 |
69 | func interruptibleAnimator(using transitionContext: UIViewControllerContextTransitioning) -> UIViewImplicitlyAnimating {
70 | return transitionAnimator(using: transitionContext)
71 | }
72 |
73 | func interruptTransition() {
74 | guard let context = context else {
75 | return
76 | }
77 | context.pauseInteractiveTransition()
78 | pause()
79 | }
80 | }
81 |
82 |
--------------------------------------------------------------------------------
/ChuckCore/TestUtilities/Resources/search.json:
--------------------------------------------------------------------------------
1 | {
2 | "total": 8,
3 | "result": [
4 | {
5 | "category": null,
6 | "icon_url": "https://assets.chucknorris.host/img/avatar/chuck-norris.png",
7 | "id": "RDCtS4GjQpmwByA3ytBs2A",
8 | "url": "https://api.chucknorris.io/jokes/RDCtS4GjQpmwByA3ytBs2A",
9 | "value": "I just downloaded the new \u0022Roundhouse Kick\u0022 app for my iPhone and now my screen is cracked and the phone does not work. Damn you, Chuck Norris."
10 | },
11 | {
12 | "category": null,
13 | "icon_url": "https://assets.chucknorris.host/img/avatar/chuck-norris.png",
14 | "id": "ulsiraupTqykbsK70ifGxw",
15 | "url": "https://api.chucknorris.io/jokes/ulsiraupTqykbsK70ifGxw",
16 | "value": "Steve Jobs died because Chuck Norris was pissed that the iPhone 4S wasn\u0027t the iPhone 5"
17 | },
18 | {
19 | "category": null,
20 | "icon_url": "https://assets.chucknorris.host/img/avatar/chuck-norris.png",
21 | "id": "hwO3REACRASgG1nClLfR_w",
22 | "url": "https://api.chucknorris.io/jokes/hwO3REACRASgG1nClLfR_w",
23 | "value": "Chuck Norris\u0027 cell phone of choice...........the Blackberry IPhone"
24 | },
25 | {
26 | "category": null,
27 | "icon_url": "https://assets.chucknorris.host/img/avatar/chuck-norris.png",
28 | "id": "RX3oS-gFS4-FkZpZQMsp6g",
29 | "url": "https://api.chucknorris.io/jokes/RX3oS-gFS4-FkZpZQMsp6g",
30 | "value": "Blackouts are caused by Chuck Norris charging his iPhone."
31 | },
32 | {
33 | "category": null,
34 | "icon_url": "https://assets.chucknorris.host/img/avatar/chuck-norris.png",
35 | "id": "fKuYuPBFQZaOCFinwassFQ",
36 | "url": "https://api.chucknorris.io/jokes/fKuYuPBFQZaOCFinwassFQ",
37 | "value": "Chuck Norris has a roundhouse-kick app for his iPhone \u0026#8734;.0"
38 | },
39 | {
40 | "category": null,
41 | "icon_url": "https://assets.chucknorris.host/img/avatar/chuck-norris.png",
42 | "id": "qUiNkzrCRwmK8EIbBzt_Ag",
43 | "url": "https://api.chucknorris.io/jokes/qUiNkzrCRwmK8EIbBzt_Ag",
44 | "value": "Chuck Norris put the I in IPhone with a real human eye"
45 | },
46 | {
47 | "category": null,
48 | "icon_url": "https://assets.chucknorris.host/img/avatar/chuck-norris.png",
49 | "id": "DHv_qJw-RXWQCzr2BXruZw",
50 | "url": "https://api.chucknorris.io/jokes/DHv_qJw-RXWQCzr2BXruZw",
51 | "value": "Chuck Norris had an iPad Air when the iPhone was first released."
52 | },
53 | {
54 | "category": null,
55 | "icon_url": "https://assets.chucknorris.host/img/avatar/chuck-norris.png",
56 | "id": "9FqrXimEQ2-XoPbAx-UcBA",
57 | "url": "https://api.chucknorris.io/jokes/9FqrXimEQ2-XoPbAx-UcBA",
58 | "value": "Adobe Flash runs on Chuck Norris\u0027 iPhone."
59 | }
60 | ]
61 | }
--------------------------------------------------------------------------------
/Chuck/Controllers/Home/EmptyViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // EmptyViewController.swift
3 | // Chuck
4 | //
5 | // Created by Guilherme Rambo on 28/05/18.
6 | // Copyright © 2018 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | protocol EmptyViewControllerDelegate: class {
12 | func emptyViewControllerDidSelectSearch(_ controller: EmptyViewController)
13 | }
14 |
15 | class EmptyViewController: UIViewController {
16 |
17 | weak var delegate: EmptyViewControllerDelegate?
18 |
19 | var message: String? {
20 | get {
21 | return infoLabel.text
22 | }
23 | set {
24 | infoLabel.text = newValue
25 | }
26 | }
27 |
28 | var actionTitle: String? {
29 | get {
30 | return actionButton.title(for: .normal)
31 | }
32 | set {
33 | let shouldHide = newValue == nil || newValue?.count == 0
34 | actionButton.isHidden = shouldHide
35 |
36 | actionButton.setTitle(newValue, for: .normal)
37 | }
38 | }
39 |
40 | private lazy var infoLabel: UILabel = {
41 | let label = UILabel()
42 |
43 | label.textAlignment = .center
44 | label.numberOfLines = 0
45 | label.lineBreakMode = .byWordWrapping
46 | label.font = .info
47 | label.textColor = .infoText
48 |
49 | return label
50 | }()
51 |
52 | private lazy var actionButton: UIButton = {
53 | let button = UIButton(type: .system)
54 |
55 | button.widthAnchor.constraint(equalToConstant: Metrics.largeButtonWidth).isActive = true
56 | button.heightAnchor.constraint(equalToConstant: Metrics.largeButtonHeight).isActive = true
57 | button.backgroundColor = .buttonBackground
58 | button.setTitleColor(.buttonText, for: .normal)
59 | button.titleLabel?.font = .largeButton
60 | button.addTarget(self, action: #selector(searchTapped), for: .touchUpInside)
61 | button.layer.cornerRadius = Metrics.buttonCornerRadius
62 |
63 | return button
64 | }()
65 |
66 | private lazy var stackView: UIStackView = {
67 | let stack = UIStackView(arrangedSubviews: [infoLabel, actionButton])
68 |
69 | stack.spacing = Metrics.extraPadding
70 | stack.axis = .vertical
71 | stack.alignment = .center
72 | stack.translatesAutoresizingMaskIntoConstraints = false
73 |
74 | return stack
75 | }()
76 |
77 | override func viewDidLoad() {
78 | super.viewDidLoad()
79 |
80 | view.isOpaque = false
81 | view.backgroundColor = .clear
82 |
83 | view.uiTestingLabel = .emptyView
84 |
85 | view.addSubview(stackView)
86 | stackView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
87 | stackView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
88 | stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: Metrics.extraPadding).isActive = true
89 | stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -Metrics.extraPadding).isActive = true
90 | }
91 |
92 | @objc private func searchTapped() {
93 | delegate?.emptyViewControllerDidSelectSearch(self)
94 | }
95 |
96 | }
97 |
--------------------------------------------------------------------------------
/ChuckCore/Networking/ChuckAPIClient.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ChuckAPIClient.swift
3 | // ChuckCore
4 | //
5 | // Created by Guilherme Rambo on 27/05/18.
6 | // Copyright © 2018 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import RxSwift
11 | import RxCocoa
12 |
13 | public enum APIResult {
14 | case success(T)
15 | case error(Error)
16 | }
17 |
18 | public final class ChuckAPIClient {
19 |
20 | private struct Constants {
21 | static let maxRetryCount = 3
22 | }
23 |
24 | public enum APIError: Error {
25 | case requestGeneration
26 | }
27 |
28 | public let environment: ChuckAPIEnvironment
29 |
30 | public init(environment: ChuckAPIEnvironment = .production) {
31 | self.environment = environment
32 | }
33 |
34 | private lazy var session: URLSession = {
35 | return URLSession(configuration: .default)
36 | }()
37 |
38 | private func localFileObservable(with url: URL?) -> Observable {
39 | guard let url = url else { return Observable.error(APIError.requestGeneration) }
40 |
41 | do {
42 | let data = try Data(contentsOf: url)
43 |
44 | return Observable.just(data).delay(1, scheduler: MainScheduler.instance)
45 | } catch {
46 | return Observable.error(error)
47 | }
48 | }
49 |
50 | private func observable(for endpoint: ChuckAPIEndpoint, responseType: T.Type) -> Observable {
51 | guard let request = endpoint.request else {
52 | return Observable.error(APIError.requestGeneration)
53 | }
54 |
55 | let dataObservable: Observable
56 |
57 | if request.url?.isFileURL == true {
58 | // File URLs are used during testing, so we skip URLSession altogether in this case
59 | dataObservable = localFileObservable(with: request.url)
60 | } else {
61 | dataObservable = session.rx.data(request: request)
62 | }
63 |
64 | return dataObservable.map { data in
65 | let decoder = JSONDecoder()
66 | decoder.keyDecodingStrategy = .convertFromSnakeCase
67 | return try decoder.decode(responseType, from: data)
68 | }
69 | }
70 |
71 | public lazy var random: Observable = {
72 | return self.observable(for: .random(environment: self.environment), responseType: Joke.self)
73 | }()
74 |
75 | public func random(with category: Category) -> Observable {
76 | return observable(for: .random(with: category.name, environment: self.environment), responseType: Joke.self)
77 | }
78 |
79 | public func random(with categoryName: String) -> Observable {
80 | return observable(for: .random(with: categoryName, environment: self.environment), responseType: Joke.self)
81 | }
82 |
83 | public lazy var categories: Observable<[ChuckCore.Category]> = {
84 | return self.observable(for: .categories(environment: self.environment), responseType: [ChuckCore.Category].self)
85 | }()
86 |
87 | public func search(with term: String) -> Observable {
88 | return observable(for: .search(with: term, environment: self.environment), responseType: SearchResponse.self)
89 | }
90 |
91 | }
92 |
--------------------------------------------------------------------------------
/ChuckCoreTests/ChuckCoreAPIResponseParsingTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ChuckCoreAPIResponseParsingTests.swift
3 | // ChuckCoreTests
4 | //
5 | // Created by Guilherme Rambo on 22/05/18.
6 | // Copyright © 2018 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import XCTest
10 | @testable import ChuckCore
11 |
12 | class ChuckCoreAPIResponseParsingTests: XCTestCase {
13 |
14 | /// Make sure all required resources are available for the tests, this test will fail if any
15 | /// of the required resources is not available in the test bundle
16 | func testBundleIntegrity() throws {
17 | try TestResource.all.forEach { _ = try Bundle.chuckCore.fetch(resource: $0) }
18 | }
19 |
20 | func testSearchResponseWithResultsParsing() throws {
21 | let data = try Bundle.chuckCore.fetch(resource: .search)
22 |
23 | let decoder = JSONDecoder()
24 | decoder.keyDecodingStrategy = .convertFromSnakeCase
25 | let response = try decoder.decode(SearchResponse.self, from: data)
26 |
27 | let firstJoke = response.result.first
28 | XCTAssertNotNil(firstJoke)
29 |
30 | XCTAssert(response.total == 8)
31 | XCTAssert(response.result.count == 8)
32 | XCTAssert(firstJoke?.id == "RDCtS4GjQpmwByA3ytBs2A")
33 | XCTAssert(firstJoke?.value == "I just downloaded the new \"Roundhouse Kick\" app for my iPhone and now my screen is cracked and the phone does not work. Damn you, Chuck Norris.")
34 | XCTAssert(firstJoke?.url.absoluteString == "https://api.chucknorris.io/jokes/RDCtS4GjQpmwByA3ytBs2A")
35 | XCTAssert(firstJoke?.iconUrl.absoluteString == "https://assets.chucknorris.host/img/avatar/chuck-norris.png")
36 | }
37 |
38 | func testEmptySearchResultsParsing() throws {
39 | let data = try Bundle.chuckCore.fetch(resource: .searchEmpty)
40 |
41 | let decoder = JSONDecoder()
42 | decoder.keyDecodingStrategy = .convertFromSnakeCase
43 | let response = try decoder.decode(SearchResponse.self, from: data)
44 |
45 | XCTAssert(response.total == 0)
46 | XCTAssertNil(response.result.first)
47 | }
48 |
49 | func testCategoriesParsing() throws {
50 | let data = try Bundle.chuckCore.fetch(resource: .categories)
51 |
52 | let categories = try JSONDecoder().decode([String].self, from: data)
53 | XCTAssert(categories.count == 16)
54 | XCTAssert(categories.first == "explicit")
55 | XCTAssert(categories.last == "fashion")
56 | }
57 |
58 | func testRandomResponseParsing() throws {
59 | let data = try Bundle.chuckCore.fetch(resource: .random)
60 |
61 | let decoder = JSONDecoder()
62 | decoder.keyDecodingStrategy = .convertFromSnakeCase
63 | let response = try decoder.decode(Joke.self, from: data)
64 |
65 | XCTAssert(response.categories.count == 0)
66 | XCTAssert(response.id == "2cZa3wC8Ts-UI4TiFaFQVw")
67 | XCTAssert(response.value == "Chuck Norris is Mysterion.")
68 | XCTAssert(response.url.absoluteString == "https://api.chucknorris.io/jokes/2cZa3wC8Ts-UI4TiFaFQVw")
69 | XCTAssert(response.iconUrl.absoluteString == "https://assets.chucknorris.host/img/avatar/chuck-norris.png")
70 | }
71 |
72 | func testRandomResponseWithCategoryParsing() throws {
73 | let response = try Joke.sample()
74 |
75 | validateSampleJokeFields(with: response)
76 | }
77 |
78 | }
79 |
--------------------------------------------------------------------------------
/ChuckCoreTests/ChuckCoreAPIClientTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ChuckCoreAPIClientTests.swift
3 | // ChuckCoreTests
4 | //
5 | // Created by Guilherme Rambo on 27/05/18.
6 | // Copyright © 2018 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import XCTest
11 |
12 | import RxSwift
13 | import RxBlocking
14 |
15 | @testable import ChuckCore
16 |
17 | class ChuckCoreAPIClientTests: XCTestCase {
18 |
19 | func testRandomEndpoint() throws {
20 | let client = ChuckAPIClient(environment: .test)
21 | let joke = try client.random.toBlocking().first()
22 |
23 | XCTAssertNotNil(joke)
24 | XCTAssertEqual(joke?.categories.count, 0)
25 | XCTAssertEqual(joke?.iconUrl.absoluteString, "https://assets.chucknorris.host/img/avatar/chuck-norris.png")
26 | XCTAssertEqual(joke?.id, "2cZa3wC8Ts-UI4TiFaFQVw")
27 | XCTAssertEqual(joke?.url.absoluteString, "https://api.chucknorris.io/jokes/2cZa3wC8Ts-UI4TiFaFQVw")
28 | XCTAssertEqual(joke?.value, "Chuck Norris is Mysterion.")
29 | }
30 |
31 | func testRandomWithCategoryEndpoint() throws {
32 | let client = ChuckAPIClient(environment: .test)
33 | let joke = try client.random(with: "test").toBlocking().first()
34 |
35 | XCTAssertNotNil(joke)
36 | XCTAssertEqual(joke?.categories.count, 1)
37 | XCTAssertEqual(joke?.categories.first?.name, "political")
38 | XCTAssertEqual(joke?.iconUrl.absoluteString, "https://assets.chucknorris.host/img/avatar/chuck-norris.png")
39 | XCTAssertEqual(joke?.id, "uanj8roxsrwq6pgb0kufia")
40 | XCTAssertEqual(joke?.url.absoluteString, "https://api.chucknorris.io/jokes/uanj8roxsrwq6pgb0kufia")
41 | XCTAssertEqual(joke?.value, "Guantuanamo Bay, Cuba, is the military code-word for \"Chuck Norris\' basement\".")
42 | }
43 |
44 | func testCategoriesEndpoint() throws {
45 | let client = ChuckAPIClient(environment: .test)
46 | let categories = try client.categories.toBlocking().first()
47 |
48 | XCTAssertNotNil(categories)
49 | XCTAssertEqual(categories?.first?.name, "explicit")
50 | XCTAssertEqual(categories?.last?.name, "fashion")
51 | }
52 |
53 | func testSearchEndpoint() throws {
54 | let client = ChuckAPIClient(environment: .test)
55 | let response = try client.search(with: "test").toBlocking().first()
56 |
57 | XCTAssertNotNil(response)
58 | XCTAssertEqual(response?.total, 8)
59 | XCTAssertEqual(response?.result.count, 8)
60 |
61 | let joke = response?.result.first
62 |
63 | XCTAssertEqual(joke?.categories.count, 0)
64 | XCTAssertEqual(joke?.iconUrl.absoluteString, "https://assets.chucknorris.host/img/avatar/chuck-norris.png")
65 | XCTAssertEqual(joke?.id, "RDCtS4GjQpmwByA3ytBs2A")
66 | XCTAssertEqual(joke?.url.absoluteString, "https://api.chucknorris.io/jokes/RDCtS4GjQpmwByA3ytBs2A")
67 | XCTAssertEqual(joke?.value, "I just downloaded the new \"Roundhouse Kick\" app for my iPhone and now my screen is cracked and the phone does not work. Damn you, Chuck Norris.")
68 | }
69 |
70 | func testSearchEndpointWithNoResults() throws {
71 | let client = ChuckAPIClient(environment: .test)
72 | let response = try client.search(with: "empty").toBlocking().first()
73 |
74 | XCTAssertNotNil(response)
75 | XCTAssert(response?.total == 0)
76 | XCTAssertNil(response?.result.first)
77 | }
78 |
79 | }
80 |
--------------------------------------------------------------------------------
/Chuck/Resources/Intents.intentdefinition:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | INEnums
6 |
7 | INIntentDefinitionModelVersion
8 | 1.0
9 | INIntentDefinitionSystemVersion
10 | 18E226
11 | INIntentDefinitionToolsBuildVersion
12 | 10E1001
13 | INIntentDefinitionToolsVersion
14 | 10.2.1
15 | INIntents
16 |
17 |
18 | INIntentCategory
19 | information
20 | INIntentDescription
21 | Get a random Chuck Norris joke from Siri
22 | INIntentDescriptionID
23 | gjMqeX
24 | INIntentLastParameterTag
25 | 0
26 | INIntentName
27 | TellJoke
28 | INIntentParameterCombinations
29 |
30 |
31 |
32 | INIntentParameterCombinationIsPrimary
33 |
34 | INIntentParameterCombinationSubtitle
35 |
36 | INIntentParameterCombinationSubtitleID
37 | qyDkMl
38 | INIntentParameterCombinationSupportsBackgroundExecution
39 |
40 | INIntentParameterCombinationTitle
41 |
42 | INIntentParameterCombinationTitleID
43 | rNK7mB
44 |
45 |
46 | INIntentParameters
47 |
48 | INIntentResponse
49 |
50 | INIntentResponseCodes
51 |
52 |
53 | INIntentResponseCodeFormatString
54 |
55 | INIntentResponseCodeFormatStringID
56 | Kx8Fj3
57 | INIntentResponseCodeName
58 | failure
59 | INIntentResponseCodeSuccess
60 |
61 |
62 |
63 | INIntentResponseCodeFormatString
64 | ${body}
65 | INIntentResponseCodeFormatStringID
66 | W7GoGr
67 | INIntentResponseCodeName
68 | success
69 | INIntentResponseCodeSuccess
70 |
71 |
72 |
73 | INIntentResponseLastParameterTag
74 | 1
75 | INIntentResponseParameters
76 |
77 |
78 | INIntentResponseParameterDisplayPriority
79 | 1
80 | INIntentResponseParameterName
81 | body
82 | INIntentResponseParameterSupportsMultipleValues
83 |
84 | INIntentResponseParameterTag
85 | 1
86 | INIntentResponseParameterType
87 | String
88 |
89 |
90 |
91 | INIntentRestrictions
92 | 0
93 | INIntentTitle
94 | Tell a Chuck Norris joke
95 | INIntentTitleID
96 | EEQeW7
97 | INIntentType
98 | Custom
99 | INIntentUserConfirmationRequired
100 |
101 | INIntentVerb
102 | View
103 |
104 |
105 |
106 |
107 |
--------------------------------------------------------------------------------
/ChuckCoreTests/ChuckCoreSyncEngineTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ChuckCoreSyncEngineTests.swift
3 | // ChuckCoreTests
4 | //
5 | // Created by Guilherme Rambo on 27/05/18.
6 | // Copyright © 2018 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import XCTest
11 |
12 | import RxSwift
13 | import RxCocoa
14 | import RxCoreData
15 | import RxBlocking
16 | import Reachability
17 |
18 | @testable import ChuckCore
19 |
20 | class ChuckCoreSyncEngineTests: XCTestCase {
21 |
22 | var engine: SyncEngine!
23 |
24 | override func setUp() {
25 | let reachability = Reachability(hostname: "localhost")!
26 |
27 | let client = ChuckAPIClient(environment: .test)
28 | engine = SyncEngine(client: client, persistentContainer: .test, reachability: reachability)
29 | }
30 |
31 | func testFetchingAllCategories() throws {
32 | try engine.syncCategories().toBlocking().single()
33 |
34 | let categories = try engine.fetchCategories().toBlocking().first()
35 |
36 | XCTAssertNotNil(categories)
37 | XCTAssertEqual(categories?.count, 16)
38 | }
39 |
40 | func testFetchingRandomCategorySelection() throws {
41 | try engine.syncCategories().toBlocking().single()
42 |
43 | let categories = try engine.fetchRandomCategories(with: 8).toBlocking().first()
44 | let categories2 = try engine.fetchRandomCategories(with: 8).toBlocking().first()
45 |
46 | XCTAssertEqual(categories?.count, 8)
47 | XCTAssertEqual(categories2?.count, 8)
48 |
49 | XCTAssertNotNil(categories)
50 | XCTAssertNotNil(categories2)
51 |
52 | // Two selections in a row must never be equal
53 | XCTAssertNotEqual(categories, categories2)
54 | }
55 |
56 | func testFetchingRandomCategorySelectionBeyondBounds() throws {
57 | try engine.syncCategories().toBlocking().single()
58 |
59 | // There are only 16 categories
60 | let categories = try engine.fetchRandomCategories(with: 17).toBlocking().first()
61 |
62 | XCTAssertNotNil(categories)
63 | XCTAssertEqual(categories?.count, 16)
64 | }
65 |
66 | func testFetchSearchResults() throws {
67 | try engine.syncSearchResults(with: "Steve Jobs").toBlocking().single()
68 |
69 | let viewModels = try engine.fetchSearchResults(with: "Steve Jobs").toBlocking().first()
70 |
71 | XCTAssertNotNil(viewModels)
72 | XCTAssertEqual(viewModels?.count, 1)
73 | XCTAssertEqual(viewModels?.first?.id, "ulsiraupTqykbsK70ifGxw")
74 | }
75 |
76 | func testFetchRandomJokes() throws {
77 | try engine.syncSearchResults(with: "iPhone").toBlocking().single()
78 |
79 | let viewModels = try engine.fetchRandomJokes(with: 5).toBlocking().first()
80 | let viewModels2 = try engine.fetchRandomJokes(with: 5).toBlocking().first()
81 |
82 | XCTAssertNotNil(viewModels)
83 | XCTAssertEqual(viewModels?.count, 5)
84 | XCTAssertEqual(viewModels2?.count, 5)
85 |
86 | XCTAssertNotEqual(viewModels, viewModels2)
87 | }
88 |
89 | func testFetchSingleJoke() throws {
90 | let joke = try engine.randomJoke().toBlocking().first()
91 |
92 | XCTAssertNotNil(joke)
93 | XCTAssertEqual(joke?.id, "2cZa3wC8Ts-UI4TiFaFQVw")
94 | }
95 |
96 | func testSearchHistoryStorage() throws {
97 | try engine.registerSearchHistory(for: "Apple")
98 | try engine.registerSearchHistory(for: "Microsoft")
99 | try engine.registerSearchHistory(for: "Google")
100 |
101 | let viewModels = try engine.fetchRecentSearches(with: 4).toBlocking().first()
102 |
103 | XCTAssertNotNil(viewModels)
104 | XCTAssertEqual(viewModels?.count, 3)
105 | XCTAssertEqual(viewModels?.first?.term, "GOOGLE")
106 | }
107 |
108 | }
109 |
--------------------------------------------------------------------------------
/TodayExtension/TodayViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TodayViewController.swift
3 | // TodayExtension
4 | //
5 | // Created by Guilherme Rambo on 15/04/19.
6 | // Copyright © 2019 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import NotificationCenter
11 | import RxSwift
12 | import RxCocoa
13 | import ChuckCore
14 |
15 | class TodayViewController: UIViewController, NCWidgetProviding {
16 |
17 | // 1. UI
18 |
19 | private let uiStack = ChuckUIStack()
20 |
21 | private lazy var effectView: UIVisualEffectView = {
22 | let v = UIVisualEffectView(effect: UIVibrancyEffect.widgetPrimary())
23 |
24 | v.autoresizingMask = [.flexibleWidth, .flexibleHeight]
25 | v.frame = view.bounds
26 |
27 | return v
28 | }()
29 |
30 | private lazy var spinner: UIActivityIndicatorView = {
31 | let v = UIActivityIndicatorView(style: .gray)
32 |
33 | v.translatesAutoresizingMaskIntoConstraints = false
34 | v.hidesWhenStopped = true
35 |
36 | return v
37 | }()
38 |
39 | private lazy var jokeLabel: UILabel = {
40 | let l = UILabel()
41 |
42 | l.font = UIFont.systemFont(ofSize: 18, weight: .medium)
43 | l.textColor = UIColor.darkText
44 | l.lineBreakMode = .byWordWrapping
45 | l.numberOfLines = 0
46 | l.autoresizingMask = [.flexibleWidth, .flexibleHeight]
47 | l.frame = view.bounds
48 |
49 | return l
50 | }()
51 |
52 | override func viewDidLoad() {
53 | super.viewDidLoad()
54 |
55 | installViews()
56 | }
57 |
58 | override func viewWillAppear(_ animated: Bool) {
59 | super.viewWillAppear(animated)
60 |
61 | jokeLabel.frame = view.bounds.insetBy(dx: 24, dy: 0)
62 |
63 | updateSize()
64 | }
65 |
66 | // MARK: - Layout
67 |
68 | private func installViews() {
69 | view.addSubview(effectView)
70 | effectView.contentView.addSubview(jokeLabel)
71 |
72 | effectView.contentView.addSubview(spinner)
73 |
74 | NSLayoutConstraint.activate([
75 | spinner.centerYAnchor.constraint(equalTo: effectView.contentView.centerYAnchor),
76 | spinner.centerXAnchor.constraint(equalTo: effectView.contentView.centerXAnchor)
77 | ])
78 | }
79 |
80 | // 2. UPDATES
81 |
82 | private let bag = DisposeBag()
83 |
84 | func widgetPerformUpdate(completionHandler: (@escaping (NCUpdateResult) -> Void)) {
85 | state = .loading
86 |
87 | uiStack.syncEngine.randomJoke().observeOn(MainScheduler.instance).bind { [weak self] joke in
88 | self?.state = .content(joke)
89 |
90 | completionHandler(NCUpdateResult.newData)
91 | }.disposed(by: bag)
92 | }
93 |
94 | private func updateSize() {
95 | NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(doUpdateSize), object: nil)
96 | perform(#selector(doUpdateSize), with: nil, afterDelay: 0)
97 | }
98 |
99 | @objc private func doUpdateSize() {
100 | preferredContentSize = jokeLabel.intrinsicContentSize
101 | }
102 |
103 | // 3. STATE
104 |
105 | enum State {
106 | case loading
107 | case content(JokeViewModel)
108 | }
109 |
110 | var state: State = .loading {
111 | didSet {
112 | updateUI()
113 | }
114 | }
115 |
116 | private func updateUI() {
117 | switch state {
118 | case .loading:
119 | spinner.startAnimating()
120 | jokeLabel.isHidden = true
121 | case .content(let joke):
122 | jokeLabel.text = joke.body
123 |
124 | spinner.stopAnimating()
125 | jokeLabel.isHidden = false
126 |
127 | updateSize()
128 | }
129 | }
130 |
131 | }
132 |
--------------------------------------------------------------------------------
/Chuck/Bootstrap/AppDelegate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppDelegate.swift
3 | // Chuck
4 | //
5 | // Created by Guilherme Rambo on 22/05/18.
6 | // Copyright © 2018 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import ChuckCore
11 | import CoreData
12 | import RxSwift
13 | import Reachability
14 | import os.log
15 |
16 | import Intents
17 |
18 | extension Notification.Name {
19 | static let ChuckErrorDidOccur = Notification.Name("ChuckErrorDidOccur")
20 | }
21 |
22 | @UIApplicationMain
23 | class AppDelegate: UIResponder, UIApplicationDelegate {
24 |
25 | private let log = OSLog(subsystem: "Chuck", category: "AppDelegate")
26 |
27 | var window: UIWindow?
28 |
29 | private let uiStack = ChuckUIStack()
30 |
31 | private lazy var flowController: AppFlowController = {
32 | return AppFlowController(syncEngine: uiStack.syncEngine)
33 | }()
34 |
35 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
36 | resetStorageIfRunningUITests()
37 |
38 | configureReachability()
39 |
40 | window = UIWindow()
41 | window?.rootViewController = flowController
42 |
43 | window?.makeKeyAndVisible()
44 |
45 | syncCategories()
46 |
47 | if #available(iOS 12.0, *) {
48 | registerShortcutSuggestions()
49 | }
50 |
51 | return true
52 | }
53 |
54 | private let disposeBag = DisposeBag()
55 |
56 | private func configureReachability() {
57 | do {
58 | try uiStack.reachability.startNotifier()
59 | } catch {
60 | os_log("Failed to start reachability: %{public}@", log: self.log, type: .error, String(describing: error))
61 | }
62 |
63 | // Sets isOffline to true in the flow controller whenever reachability is not connected
64 | uiStack.reachability.rx.isReachable.map({ !$0 }).bind(to: flowController.isOffline).disposed(by: disposeBag)
65 | }
66 |
67 | private func syncCategories() {
68 | uiStack.syncEngine.syncCategories().subscribeOn(MainScheduler.instance).subscribe(onError: { [weak self] error in
69 | guard let `self` = self else { return }
70 |
71 | os_log(
72 | "Failed to sync categories: %{public}@",
73 | log: self.log,
74 | type: .error,
75 | String(describing: error)
76 | )
77 |
78 | NotificationCenter.default.post(name: .ChuckErrorDidOccur, object: error)
79 | }, onCompleted: { [weak self] in
80 | guard let `self` = self else { return }
81 |
82 | os_log("Synced categories", log: self.log, type: .info)
83 | }).disposed(by: disposeBag)
84 | }
85 |
86 | private func resetStorageIfRunningUITests() {
87 | guard TestArguments.isRunningUITests else { return }
88 | guard let bundleId = Bundle.main.bundleIdentifier else { return }
89 |
90 | // Clear user defaults
91 | UserDefaults.standard.removePersistentDomain(forName: bundleId)
92 |
93 | // Clear storage
94 | do {
95 | try uiStack.syncEngine.clearDatabase()
96 | } catch {
97 | fatalError(String(describing: error))
98 | }
99 | }
100 |
101 | @available(iOS 12.0, *)
102 | private func registerShortcutSuggestions() {
103 | let intent = TellJokeIntent()
104 | intent.suggestedInvocationPhrase = "Tell me a Chuck joke"
105 |
106 | guard let shortcut = INShortcut(intent: intent) else {
107 | os_log("Failed to register shortcut suggestions!", log: self.log, type: .fault)
108 | return
109 | }
110 |
111 | INVoiceShortcutCenter.shared.setShortcutSuggestions([shortcut])
112 |
113 | os_log("Registered shortcut suggestions", log: self.log, type: .default)
114 | }
115 |
116 | }
117 |
--------------------------------------------------------------------------------
/Chuck/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | },
6 | "images" : [
7 | {
8 | "filename" : "Icon-40.png",
9 | "size" : "40x40",
10 | "idiom" : "ipad",
11 | "scale" : "1x"
12 | },
13 | {
14 | "filename" : "Icon-40@2x.png",
15 | "size" : "40x40",
16 | "idiom" : "ipad",
17 | "scale" : "2x"
18 | },
19 | {
20 | "filename" : "Icon-60@2x.png",
21 | "size" : "60x60",
22 | "idiom" : "iphone",
23 | "scale" : "2x"
24 | },
25 | {
26 | "filename" : "Icon-72.png",
27 | "size" : "72x72",
28 | "idiom" : "ipad",
29 | "scale" : "1x"
30 | },
31 | {
32 | "filename" : "Icon-72@2x.png",
33 | "size" : "72x72",
34 | "idiom" : "ipad",
35 | "scale" : "2x"
36 | },
37 | {
38 | "filename" : "Icon-76.png",
39 | "size" : "76x76",
40 | "idiom" : "ipad",
41 | "scale" : "1x"
42 | },
43 | {
44 | "filename" : "Icon-76@2x.png",
45 | "size" : "76x76",
46 | "idiom" : "ipad",
47 | "scale" : "2x"
48 | },
49 | {
50 | "filename" : "Icon-Small-50.png",
51 | "size" : "50x50",
52 | "idiom" : "ipad",
53 | "scale" : "1x"
54 | },
55 | {
56 | "filename" : "Icon-Small-50@2x.png",
57 | "size" : "50x50",
58 | "idiom" : "ipad",
59 | "scale" : "2x"
60 | },
61 | {
62 | "filename" : "Icon-Small.png",
63 | "size" : "29x29",
64 | "idiom" : "iphone",
65 | "scale" : "1x"
66 | },
67 | {
68 | "filename" : "Icon-Small@2x.png",
69 | "size" : "29x29",
70 | "idiom" : "iphone",
71 | "scale" : "2x"
72 | },
73 | {
74 | "filename" : "Icon.png",
75 | "size" : "57x57",
76 | "idiom" : "iphone",
77 | "scale" : "1x"
78 | },
79 | {
80 | "filename" : "Icon@2x.png",
81 | "size" : "57x57",
82 | "idiom" : "iphone",
83 | "scale" : "2x"
84 | },
85 | {
86 | "filename" : "Icon-Small@3x.png",
87 | "size" : "29x29",
88 | "idiom" : "iphone",
89 | "scale" : "3x"
90 | },
91 | {
92 | "filename" : "Icon-40@3x.png",
93 | "size" : "40x40",
94 | "idiom" : "iphone",
95 | "scale" : "3x"
96 | },
97 | {
98 | "filename" : "Icon-60@3x.png",
99 | "size" : "60x60",
100 | "idiom" : "iphone",
101 | "scale" : "3x"
102 | },
103 | {
104 | "filename" : "Icon-40@2x.png",
105 | "size" : "40x40",
106 | "idiom" : "iphone",
107 | "scale" : "2x"
108 | },
109 | {
110 | "filename" : "Icon-Small.png",
111 | "size" : "29x29",
112 | "idiom" : "ipad",
113 | "scale" : "1x"
114 | },
115 | {
116 | "filename" : "Icon-Small@2x.png",
117 | "size" : "29x29",
118 | "idiom" : "ipad",
119 | "scale" : "2x"
120 | },
121 | {
122 | "filename" : "Icon-83.5@2x.png",
123 | "size" : "83.5x83.5",
124 | "idiom" : "ipad",
125 | "scale" : "2x"
126 | },
127 | {
128 | "filename" : "NotificationIcon@2x.png",
129 | "size" : "20x20",
130 | "idiom" : "iphone",
131 | "scale" : "2x"
132 | },
133 | {
134 | "filename" : "NotificationIcon@3x.png",
135 | "size" : "20x20",
136 | "idiom" : "iphone",
137 | "scale" : "3x"
138 | },
139 | {
140 | "filename" : "NotificationIcon~ipad.png",
141 | "size" : "20x20",
142 | "idiom" : "ipad",
143 | "scale" : "1x"
144 | },
145 | {
146 | "filename" : "NotificationIcon~ipad@2x.png",
147 | "size" : "20x20",
148 | "idiom" : "ipad",
149 | "scale" : "2x"
150 | },
151 | {
152 | "filename" : "ios-marketing.png",
153 | "size" : "1024x1024",
154 | "idiom" : "ios-marketing",
155 | "scale" : "1x"
156 | }
157 | ]
158 | }
--------------------------------------------------------------------------------
/Chuck/Controllers/Search/BadgesCollectionViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BadgesCollectionViewController.swift
3 | // Chuck
4 | //
5 | // Created by Guilherme Rambo on 28/05/18.
6 | // Copyright © 2018 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import RxSwift
11 | import RxCocoa
12 | import RxDataSources
13 | import ChuckCore
14 |
15 | private struct BadgeSectionModel: AnimatableSectionModelType {
16 |
17 | static let cellIdentifier = "badgeCell"
18 |
19 | var identity: String {
20 | return "Facts"
21 | }
22 |
23 | typealias Identity = String
24 | typealias Item = String
25 |
26 | var items: [String]
27 |
28 | init(original: BadgeSectionModel, items: [String]) {
29 | self = original
30 | self.items = items
31 | }
32 |
33 | init(items: [String]) {
34 | self.items = items
35 | }
36 |
37 | }
38 |
39 | protocol BadgesCollectionViewControllerDelegate: class {
40 | func badgesCollectionViewController(_ controller: BadgesCollectionViewController, didSelectItemWithTitle title: String)
41 | }
42 |
43 | class BadgesCollectionViewController: UIViewController {
44 |
45 | var uiTestingLabelForCells: UITestingLabel = .categoryBadge
46 |
47 | weak var delegate: BadgesCollectionViewControllerDelegate?
48 |
49 | lazy var badgeTitles = Variable<[String]>([])
50 |
51 | override func loadView() {
52 | view = UIView()
53 | view.backgroundColor = .clear
54 | view.isOpaque = false
55 | }
56 |
57 | override func viewDidLoad() {
58 | super.viewDidLoad()
59 |
60 | installCollectionView()
61 | }
62 |
63 | private lazy var flowLayout: BadgeFlowLayout = {
64 | let flow = BadgeFlowLayout()
65 |
66 | flow.estimatedItemSize = CGSize(width: 112, height: 24)
67 | flow.minimumLineSpacing = Metrics.padding / 2
68 | flow.minimumInteritemSpacing = Metrics.padding / 2
69 |
70 | return flow
71 | }()
72 |
73 | private lazy var collectionView: SelfSizingCollectionView = {
74 | let collection = SelfSizingCollectionView(frame: .zero, collectionViewLayout: flowLayout)
75 |
76 | collection.translatesAutoresizingMaskIntoConstraints = false
77 | collection.setContentCompressionResistancePriority(.required, for: .vertical)
78 |
79 | return collection
80 | }()
81 |
82 | private func installCollectionView() {
83 | view.addSubview(collectionView)
84 | collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
85 | collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
86 | collectionView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
87 | collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
88 |
89 | bindCollectionView()
90 | }
91 |
92 | private let disposeBag = DisposeBag()
93 |
94 | private func bindCollectionView() {
95 | collectionView.register(BadgeCollectionViewCell.self, forCellWithReuseIdentifier: BadgeSectionModel.cellIdentifier)
96 |
97 | let dataSource = RxCollectionViewSectionedReloadDataSource(configureCell: { [weak self] _, collectionView, indexPath, title in
98 | let cell = collectionView.dequeueReusableCell(withReuseIdentifier: BadgeSectionModel.cellIdentifier, for: indexPath) as! BadgeCollectionViewCell
99 |
100 | cell.title = title
101 | cell.uiTestingLabel = self?.uiTestingLabelForCells
102 |
103 | cell.action = { title in
104 | guard let `self` = self else { return }
105 | self.delegate?.badgesCollectionViewController(self, didSelectItemWithTitle: title)
106 | }
107 |
108 | return cell
109 | }, configureSupplementaryView: { _, _, _, _ in fatalError() })
110 |
111 | let sections = badgeTitles.asObservable().map({ [BadgeSectionModel(items: $0)] })
112 | sections.bind(to: collectionView.rx.items(dataSource: dataSource)).disposed(by: disposeBag)
113 | }
114 |
115 | }
116 |
--------------------------------------------------------------------------------
/Chuck/Controllers/Search/Transition/SearchPresentationDriver.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SearchPresentationDriver.swift
3 | // Chuck
4 | //
5 | // Created by Guilherme Rambo on 28/05/18.
6 | // Copyright © 2018 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | final class SearchPresentationDriver: NSObject {
12 |
13 | var isPresentingSearch = false
14 | private var isDragging = false
15 |
16 | weak var presenter: UIViewController?
17 | let searchController: SearchViewController
18 |
19 | init(searchController: SearchViewController, presenter: UIViewController) {
20 | self.presenter = presenter
21 | self.searchController = searchController
22 |
23 | super.init()
24 | }
25 |
26 | private lazy var searchPresentationTransition: SearchTransition = {
27 | let transition = SearchTransition()
28 |
29 | transition.auxAnimations = { [weak self] in
30 | self?.searchController.configureWithPresentedState()
31 | }
32 |
33 | return transition
34 | }()
35 |
36 | private lazy var searchDismissalTransition: SearchTransition = {
37 | let transition = SearchTransition()
38 |
39 | transition.isDismissing = true
40 |
41 | transition.auxAnimations = { [weak self] in
42 | self?.searchController.configureWithDismissedState()
43 | }
44 |
45 | return transition
46 | }()
47 |
48 | func presentSearch(interactive: Bool = false) {
49 | searchController.configureWithDismissedState()
50 |
51 | isPresentingSearch = true
52 |
53 | searchController.transitioningDelegate = self
54 | searchPresentationTransition.wantsInteractiveStart = interactive
55 |
56 | presenter?.present(searchController, animated: true, completion: nil)
57 | }
58 |
59 | private let threshold: CGFloat = 20
60 | private let limit: CGFloat = 80
61 |
62 | }
63 |
64 | extension SearchPresentationDriver: UIScrollViewDelegate {
65 |
66 | func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
67 | isDragging = true
68 | }
69 |
70 | func scrollViewDidScroll(_ scrollView: UIScrollView) {
71 | guard isDragging else {
72 | return
73 | }
74 |
75 | let adjustedOffsetY = scrollView.contentOffset.y+scrollView.adjustedContentInset.top
76 |
77 | if !isPresentingSearch && adjustedOffsetY < -threshold {
78 | isPresentingSearch = true
79 | presentSearch(interactive: true)
80 | return
81 | }
82 |
83 | if isPresentingSearch {
84 | let progress = max(0.0, min(1.0, ((-adjustedOffsetY) - threshold) / limit))
85 | searchPresentationTransition.update(progress)
86 | }
87 | }
88 |
89 | func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) {
90 | let adjustedOffsetY = scrollView.contentOffset.y+scrollView.adjustedContentInset.top
91 |
92 | let progress = max(0.0, min(1.0, ((-adjustedOffsetY) - threshold) / limit))
93 |
94 | if progress > 0.5 {
95 | searchPresentationTransition.finish()
96 | } else {
97 | searchPresentationTransition.cancel()
98 | }
99 |
100 | isPresentingSearch = false
101 | isDragging = false
102 | }
103 |
104 | }
105 |
106 | extension SearchPresentationDriver: UIViewControllerTransitioningDelegate {
107 |
108 | func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
109 | guard presented is SearchViewController else { return nil }
110 |
111 | return searchPresentationTransition
112 | }
113 |
114 | func interactionControllerForPresentation(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
115 | return searchPresentationTransition
116 | }
117 |
118 | func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
119 | guard dismissed is SearchViewController else { return nil }
120 |
121 | return searchDismissalTransition
122 | }
123 |
124 | }
125 |
--------------------------------------------------------------------------------
/Chuck/Controllers/Search/SearchSuggestionsViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SearchSuggestionsViewController.swift
3 | // Chuck
4 | //
5 | // Created by Guilherme Rambo on 28/05/18.
6 | // Copyright © 2018 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import RxSwift
11 | import RxCocoa
12 | import RxDataSources
13 | import ChuckCore
14 |
15 | protocol SearchSuggestionsViewControllerDelegate: class {
16 | func searchSuggestionsViewController(_ controller: SearchSuggestionsViewController, didSelectSuggestionWithTerm term: String)
17 | }
18 |
19 | class SearchSuggestionsViewController: UIViewController {
20 |
21 | weak var delegate: SearchSuggestionsViewControllerDelegate?
22 |
23 | lazy var categories = Variable<[CategoryViewModel]>([])
24 | lazy var recents = Variable<[RecentSearchViewModel]>([])
25 |
26 | private lazy var categoriesController: BadgesCollectionViewController = {
27 | let controller = BadgesCollectionViewController()
28 |
29 | controller.uiTestingLabelForCells = .categoryBadge
30 | controller.delegate = self
31 |
32 | return controller
33 | }()
34 |
35 | private lazy var recentsController: BadgesCollectionViewController = {
36 | let controller = BadgesCollectionViewController()
37 |
38 | controller.uiTestingLabelForCells = .recentSearchBadge
39 | controller.delegate = self
40 |
41 | return controller
42 | }()
43 |
44 | private lazy var categoriesLabel: UILabel = {
45 | let l = UILabel()
46 |
47 | l.font = .info
48 | l.textColor = .infoText
49 | l.text = "SUGGESTIONS"
50 |
51 | return l
52 | }()
53 |
54 | private lazy var categoriesStack: UIStackView = {
55 | let stack = UIStackView(arrangedSubviews: [categoriesLabel, categoriesController.view])
56 |
57 | stack.spacing = Metrics.padding / 2
58 | stack.axis = .vertical
59 |
60 | return stack
61 | }()
62 |
63 | private lazy var recentsLabel: UILabel = {
64 | let l = UILabel()
65 |
66 | l.font = .info
67 | l.textColor = .infoText
68 | l.text = "RECENTS"
69 |
70 | return l
71 | }()
72 |
73 | private lazy var recentsStack: UIStackView = {
74 | let stack = UIStackView(arrangedSubviews: [recentsLabel, recentsController.view])
75 |
76 | stack.spacing = Metrics.padding / 2
77 | stack.axis = .vertical
78 |
79 | return stack
80 | }()
81 |
82 | private lazy var mainStack: UIStackView = {
83 | let stack = UIStackView(arrangedSubviews: [categoriesStack, recentsStack])
84 |
85 | stack.spacing = Metrics.extraPadding
86 | stack.axis = .vertical
87 | stack.translatesAutoresizingMaskIntoConstraints = false
88 |
89 | return stack
90 | }()
91 |
92 | override func viewDidLoad() {
93 | super.viewDidLoad()
94 |
95 | installUI()
96 | bindUI()
97 | }
98 |
99 | private func installUI() {
100 | addChildViewController(categoriesController)
101 | addChildViewController(recentsController)
102 |
103 | view.addSubview(mainStack)
104 | mainStack.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
105 | mainStack.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
106 | mainStack.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
107 | mainStack.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
108 |
109 | categoriesController.didMove(toParentViewController: self)
110 | recentsController.didMove(toParentViewController: self)
111 | }
112 |
113 | private let disposeBag = DisposeBag()
114 |
115 | private func bindUI() {
116 | // Hide categories when there are no categories to show
117 | categories.asObservable().map({ $0.count == 0 }).bind(to: categoriesStack.rx.isHidden).disposed(by: disposeBag)
118 |
119 | // Hide recent searches when there are no recent searches to show
120 | recents.asObservable().map({ $0.count == 0 }).bind(to: recentsStack.rx.isHidden).disposed(by: disposeBag)
121 |
122 | categories.asObservable().map({ $0.map { $0.name } }).bind(to: categoriesController.badgeTitles).disposed(by: disposeBag)
123 | recents.asObservable().map({ $0.map { $0.term } }).bind(to: recentsController.badgeTitles).disposed(by: disposeBag)
124 | }
125 |
126 | }
127 |
128 | extension SearchSuggestionsViewController: BadgesCollectionViewControllerDelegate {
129 |
130 | func badgesCollectionViewController(_ controller: BadgesCollectionViewController, didSelectItemWithTitle title: String) {
131 | delegate?.searchSuggestionsViewController(self, didSelectSuggestionWithTerm: title)
132 | }
133 |
134 | }
135 |
--------------------------------------------------------------------------------
/TellJokeIntentsUI/Base.lproj/MainInterface.storyboard:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
--------------------------------------------------------------------------------
/ChuckCore/UI Support/SyncEngine.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SyncEngine.swift
3 | // ChuckCore
4 | //
5 | // Created by Guilherme Rambo on 27/05/18.
6 | // Copyright © 2018 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import CoreData
11 | import RxCoreData
12 | import RxSwift
13 | import RxCocoa
14 | import RxDataSources
15 | import Reachability
16 |
17 | public final class SyncEngine {
18 |
19 | public let reconnectionTimeout: TimeInterval = 10.0
20 |
21 | public let client: ChuckAPIClient
22 | public let persistentContainer: NSPersistentContainer
23 | public let reachability: Reachability
24 |
25 | public init(client: ChuckAPIClient, persistentContainer: NSPersistentContainer, reachability: Reachability) {
26 | self.client = client
27 | self.persistentContainer = persistentContainer
28 | self.reachability = reachability
29 | }
30 |
31 | private var moc: NSManagedObjectContext {
32 | return persistentContainer.viewContext
33 | }
34 |
35 | // MARK: - Jokes
36 |
37 | /// Syncs search results from the API for the provided term
38 | ///
39 | /// - Parameter term: the term to search for
40 | /// - Returns: An Observable that can be used to check for completion and errors
41 | public func syncSearchResults(with term: String) -> Observable {
42 | return client.search(with: term).observeOn(MainScheduler.instance).do(onNext: { [unowned self] response in
43 | try response.result.forEach(self.moc.rx.update)
44 | }).retryOnConnect(timeout: reconnectionTimeout).map { _ in Void() }
45 | }
46 |
47 | /// Performs a fetch on the database for jokes matching the term specified
48 | ///
49 | /// - Parameter term: the term to search for
50 | /// - Returns: all jokes matching the specified term
51 | public func fetchSearchResults(with term: String) -> Observable<[JokeViewModel]> {
52 | let predicate = NSPredicate(format: "value CONTAINS[cd] %@", term)
53 |
54 | return moc.rx.entities(Joke.self, predicate: predicate).map { jokes in
55 | return jokes.map(JokeViewModel.init)
56 | }
57 | }
58 |
59 | /// Performs a search on the API for a random joke
60 | /// The result is also persisted to the local database
61 | ///
62 | /// - Returns: An Observable with a random joke fresh from the API
63 | public func randomJoke() -> Observable {
64 | return client.random.retryOnConnect(timeout: reconnectionTimeout).observeOn(MainScheduler.instance).do(onNext: { [unowned self] joke in
65 | try self.moc.rx.update(joke)
66 | }).map(JokeViewModel.init)
67 | }
68 |
69 | /// Performs a search on the API for a random joke with the specified category
70 | /// The result is also persisted to the local database
71 | ///
72 | /// - Parameter categoryName: the category name to get a random joke for
73 | /// - Returns: An Observable with a random joke in the specified category fresh from the API
74 | public func randomJoke(with categoryName: String) -> Observable {
75 | return client.random(with: categoryName).retryOnConnect(timeout: reconnectionTimeout).observeOn(MainScheduler.instance).do(onNext: { [unowned self] joke in
76 | try self.moc.rx.update(joke)
77 | }).map(JokeViewModel.init)
78 | }
79 |
80 | /// Performs a fetch on the database and returns a random selection of jokes
81 | ///
82 | /// - Parameter count: the number of jokes to fetch
83 | /// - Returns: An observable with a random selection of jokes with the amount specified (limited by the number of jokes available)
84 | public func fetchRandomJokes(with count: Int) -> Observable<[JokeViewModel]> {
85 | let randomJokes: Observable<[Joke]> = moc.rx.entities(Joke.self).map { jokes in
86 | return jokes.randomSelection(with: count)
87 | }
88 |
89 | return randomJokes.map { jokes in
90 | return jokes.map(JokeViewModel.init)
91 | }
92 | }
93 |
94 | // MARK: - Categories
95 |
96 | /// Syncs categories from the API and stores the results in the database
97 | ///
98 | /// - Returns: An Observable that can be used to check for completion and errors
99 | public func syncCategories() -> Observable {
100 | return client.categories.retryOnConnect(timeout: reconnectionTimeout).observeOn(MainScheduler.instance).do(onNext: { [unowned self] categories in
101 | try categories.forEach(self.moc.rx.update)
102 | }).map { _ in Void() }
103 | }
104 |
105 | /// Performs a fetch on the database to get all categories
106 | ///
107 | /// - Returns: An Observable for an array of CategoryViewModel for the UI
108 | public func fetchCategories() -> Observable<[CategoryViewModel]> {
109 | return moc.rx.entities(Category.self).map { categories in
110 | return categories.map(CategoryViewModel.init)
111 | }
112 | }
113 |
114 | /// Performs a fetch on the database and returns a random selection of categories
115 | ///
116 | /// - Parameter count: The number of categories to get
117 | /// - Returns: An observable with a random selection of categories with the amount specified (limited by the number of categories available)
118 | public func fetchRandomCategories(with count: Int = 8) -> Observable<[CategoryViewModel]> {
119 | let randomCategories: Observable<[Category]> = moc.rx.entities(Category.self).map { categories in
120 | return categories.randomSelection(with: count)
121 | }
122 |
123 | return randomCategories.map { categories in
124 | return categories.map(CategoryViewModel.init)
125 | }
126 | }
127 |
128 | /// MARK: - Search history
129 |
130 | public func registerSearchHistory(for term: String) throws {
131 | try moc.rx.update(RecentSearch(term: term))
132 | }
133 |
134 | public func fetchRecentSearches(with count: Int) -> Observable<[RecentSearchViewModel]> {
135 | let sortDescriptor = NSSortDescriptor(key: "createdAt", ascending: false)
136 |
137 | let results = moc.rx.entities(RecentSearch.self, sortDescriptors: [sortDescriptor])
138 |
139 | return results.map { searches in
140 | return searches.map(RecentSearchViewModel.init)
141 | }
142 | }
143 |
144 | /// MARK: - Testing support
145 |
146 | public func clearDatabase() throws {
147 | let entityNames = ["Joke", "Category", "RecentSearch"]
148 |
149 | try entityNames.forEach { name in
150 | let fetch = NSFetchRequest(entityName: name)
151 | let request = NSBatchDeleteRequest(fetchRequest: fetch)
152 | try moc.execute(request)
153 | }
154 | }
155 |
156 | }
157 |
--------------------------------------------------------------------------------
/Chuck/Views/JokeTableViewCell.swift:
--------------------------------------------------------------------------------
1 | //
2 | // JokeTableViewCell.swift
3 | // Chuck
4 | //
5 | // Created by Guilherme Rambo on 27/05/18.
6 | // Copyright © 2018 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import ChuckCore
11 |
12 | class JokeTableViewCell: UITableViewCell {
13 |
14 | var didSelectShare: (() -> Void)?
15 |
16 | var viewModel: JokeViewModel? {
17 | didSet {
18 | update()
19 | }
20 | }
21 |
22 | override init(style: UITableViewCellStyle, reuseIdentifier: String?) {
23 | super.init(style: style, reuseIdentifier: reuseIdentifier)
24 |
25 | setup()
26 | }
27 |
28 | required init?(coder aDecoder: NSCoder) {
29 | super.init(coder: aDecoder)
30 |
31 | setup()
32 | }
33 |
34 | private lazy var shadowView: UIView = {
35 | let view = UIView()
36 |
37 | view.translatesAutoresizingMaskIntoConstraints = false
38 | view.backgroundColor = .white
39 | view.layer.cornerRadius = Metrics.cardCornerRadius
40 |
41 | view.layer.shadowColor = UIColor.black.cgColor
42 | view.layer.shadowOpacity = 0.1
43 | view.layer.shadowOffset = .zero
44 | view.layer.shadowRadius = 12
45 |
46 | return view
47 | }()
48 |
49 | private lazy var bodyLabel: UILabel = {
50 | let label = UILabel()
51 |
52 | label.translatesAutoresizingMaskIntoConstraints = false
53 | label.lineBreakMode = .byWordWrapping
54 | label.numberOfLines = 0
55 |
56 | return label
57 | }()
58 |
59 | private lazy var badgeView: BadgeView = {
60 | let badge = BadgeView()
61 |
62 | badge.translatesAutoresizingMaskIntoConstraints = false
63 |
64 | return badge
65 | }()
66 |
67 | private lazy var shareButton: UIButton = {
68 | let button = UIButton(type: .system)
69 |
70 | button.translatesAutoresizingMaskIntoConstraints = false
71 | button.accessibilityLabel = "Share"
72 | button.setImage(#imageLiteral(resourceName: "share"), for: .normal)
73 | button.addTarget(self, action: #selector(didTapShareButton), for: .touchUpInside)
74 |
75 | return button
76 | }()
77 |
78 | private func setup() {
79 | uiTestingLabel = .jokeCell
80 |
81 | clipsToBounds = false
82 | contentView.clipsToBounds = false
83 |
84 | contentView.addSubview(shadowView)
85 | shadowView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: Metrics.padding).isActive = true
86 | shadowView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -Metrics.padding).isActive = true
87 | shadowView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: Metrics.padding).isActive = true
88 | shadowView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -Metrics.padding).isActive = true
89 |
90 | shadowView.addSubview(bodyLabel)
91 | shadowView.addSubview(badgeView)
92 | shadowView.addSubview(shareButton)
93 |
94 | bodyLabel.topAnchor.constraint(equalTo: shadowView.topAnchor, constant: Metrics.padding).isActive = true
95 | bodyLabel.leadingAnchor.constraint(equalTo: shadowView.leadingAnchor, constant: Metrics.padding).isActive = true
96 | bodyLabel.trailingAnchor.constraint(equalTo: shadowView.trailingAnchor, constant: -Metrics.padding).isActive = true
97 |
98 | badgeView.leadingAnchor.constraint(equalTo: shadowView.leadingAnchor, constant: Metrics.padding).isActive = true
99 | badgeView.bottomAnchor.constraint(equalTo: shadowView.bottomAnchor, constant: -Metrics.padding).isActive = true
100 | badgeView.topAnchor.constraint(equalTo: bodyLabel.bottomAnchor, constant: Metrics.padding).isActive = true
101 |
102 | shareButton.trailingAnchor.constraint(equalTo: shadowView.trailingAnchor, constant: -Metrics.padding).isActive = true
103 | shareButton.bottomAnchor.constraint(equalTo: shadowView.bottomAnchor, constant: -Metrics.padding).isActive = true
104 | }
105 |
106 | private func update() {
107 | guard let viewModel = viewModel else { return }
108 |
109 | bodyLabel.font = UIFont.systemFont(ofSize: viewModel.preferredMetrics.fontSize, weight: viewModel.preferredMetrics.fontWeight)
110 | bodyLabel.text = viewModel.body
111 | badgeView.title = viewModel.categoryName
112 | }
113 |
114 | // MARK: - Interaction animation
115 |
116 | private var isFingerDown = false
117 |
118 | override func touchesBegan(_ touches: Set, with event: UIEvent?) {
119 | super.touchesBegan(touches, with: event)
120 |
121 | isFingerDown = true
122 | contract()
123 | }
124 |
125 | override func touchesEnded(_ touches: Set, with event: UIEvent?) {
126 | super.touchesEnded(touches, with: event)
127 |
128 | isFingerDown = false
129 | expand()
130 | }
131 |
132 | override func touchesCancelled(_ touches: Set, with event: UIEvent?) {
133 | super.touchesCancelled(touches, with: event)
134 |
135 | isFingerDown = false
136 | expand()
137 | }
138 |
139 | private lazy var feedbackGenerator: UIImpactFeedbackGenerator = {
140 | return UIImpactFeedbackGenerator(style: .medium)
141 | }()
142 |
143 | private let animationOptions: UIViewAnimationOptions = [
144 | .beginFromCurrentState,
145 | .allowAnimatedContent,
146 | .allowUserInteraction
147 | ]
148 |
149 | private func contract() {
150 | feedbackGenerator.prepare()
151 |
152 | UIView.animate(withDuration: 0.4, delay: 0, usingSpringWithDamping: 0.4, initialSpringVelocity: 1.2, options: animationOptions, animations: {
153 | self.shadowView.layer.transform = CATransform3DMakeScale(0.92, 0.92, 1)
154 | self.shareButton.layer.transform = CATransform3DMakeScale(1.2, 1.2, 1)
155 | }, completion: { _ in
156 | self.performHapticsAndCallActionIfAppropriate()
157 | })
158 | }
159 |
160 | private func expand() {
161 | UIView.animate(withDuration: 0.7, delay: 0.2, usingSpringWithDamping: 1.3, initialSpringVelocity: 0.9, options: animationOptions, animations: {
162 | self.shadowView.layer.transform = CATransform3DIdentity
163 | self.shareButton.layer.transform = CATransform3DIdentity
164 | }, completion: nil)
165 | }
166 |
167 | // MARK: - Actions
168 |
169 | private func performHapticsAndCallActionIfAppropriate() {
170 | guard isFingerDown else { return }
171 |
172 | feedbackGenerator.impactOccurred()
173 | didSelectShare?()
174 | }
175 |
176 | @objc private func didTapShareButton() {
177 | feedbackGenerator.impactOccurred()
178 | didSelectShare?()
179 | }
180 |
181 | }
182 |
--------------------------------------------------------------------------------
/Chuck/Controllers/Search/SearchViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SearchViewController.swift
3 | // Chuck
4 | //
5 | // Created by Guilherme Rambo on 28/05/18.
6 | // Copyright © 2018 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import RxSwift
11 | import RxCocoa
12 | import RxDataSources
13 | import ChuckCore
14 | import os.log
15 |
16 | protocol SearchViewControllerDelegate: class {
17 | func searchViewControllerWantsToBeDismissed(_ controller: SearchViewController)
18 | func searchViewController(_ controller: SearchViewController, didSearchForTerm term: String)
19 | }
20 |
21 | final class SearchViewController: UIViewController {
22 |
23 | private let log = OSLog(subsystem: "Chuck", category: "SearchViewController")
24 |
25 | weak var delegate: SearchViewControllerDelegate?
26 |
27 | let syncEngine: SyncEngine
28 |
29 | init(syncEngine: SyncEngine) {
30 | self.syncEngine = syncEngine
31 |
32 | super.init(nibName: nil, bundle: nil)
33 |
34 | modalPresentationStyle = .overCurrentContext
35 | modalPresentationCapturesStatusBarAppearance = true
36 | }
37 |
38 | required init?(coder aDecoder: NSCoder) {
39 | fatalError("Not supported")
40 | }
41 |
42 | private lazy var blurEffect = UIBlurEffect(style: .dark)
43 |
44 | private lazy var backgroundView: UIVisualEffectView = {
45 | let background = UIVisualEffectView(effect: blurEffect)
46 |
47 | background.autoresizingMask = [.flexibleWidth, .flexibleHeight]
48 |
49 | let tap = UITapGestureRecognizer(target: self, action: #selector(backgroundTapped))
50 | background.addGestureRecognizer(tap)
51 |
52 | return background
53 | }()
54 |
55 | private lazy var vibrancyView: UIVisualEffectView = {
56 | let vibrancy = UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: blurEffect))
57 |
58 | vibrancy.autoresizingMask = [.flexibleWidth, .flexibleHeight]
59 |
60 | return vibrancy
61 | }()
62 |
63 | private func makeSearchBarBackground() -> UIImage {
64 | let width = view.bounds.size.width + Metrics.padding
65 |
66 | let rect = CGRect(x: 0, y: 0, width: width, height: Metrics.searchBarHeight)
67 |
68 | UIGraphicsBeginImageContextWithOptions(rect.size, false, UIScreen.main.scale)
69 |
70 | guard let ctx = UIGraphicsGetCurrentContext() else { return UIImage() }
71 |
72 | UIBezierPath(roundedRect: rect, cornerRadius: Metrics.searchBarRadius).addClip()
73 |
74 | ctx.setFillColor(UIColor.white.withAlphaComponent(0.7).cgColor)
75 |
76 | ctx.fill(rect)
77 |
78 | let image = UIGraphicsGetImageFromCurrentImageContext()
79 |
80 | UIGraphicsEndImageContext()
81 |
82 | return image ?? UIImage()
83 | }
84 |
85 | private lazy var searchBar: UISearchBar = {
86 | let bar = UISearchBar()
87 |
88 | bar.translatesAutoresizingMaskIntoConstraints = false
89 | bar.placeholder = "Search for Facts"
90 | bar.barStyle = .black
91 | bar.keyboardAppearance = .dark
92 | bar.backgroundImage = UIImage()
93 | bar.uiTestingLabel = .searchBar
94 |
95 | bar.setSearchFieldBackgroundImage(makeSearchBarBackground(), for: .normal)
96 |
97 | if let field = bar.value(forKey: "searchField") as? UITextField,
98 | let placeholderLabel = field.value(forKey: "placeholderLabel") as? UILabel
99 | {
100 | placeholderLabel.font = UIFont.systemFont(ofSize: 16)
101 | placeholderLabel.textColor = .black
102 | }
103 |
104 | bar.searchTextPositionAdjustment = UIOffset(horizontal: Metrics.padding, vertical: 0)
105 | bar.delegate = self
106 |
107 | return bar
108 | }()
109 |
110 | lazy var suggestionsController: SearchSuggestionsViewController = {
111 | let controller = SearchSuggestionsViewController()
112 |
113 | controller.delegate = self
114 | controller.view.translatesAutoresizingMaskIntoConstraints = false
115 |
116 | return controller
117 | }()
118 |
119 | override func viewDidLoad() {
120 | super.viewDidLoad()
121 |
122 | view.uiTestingLabel = .searchView
123 |
124 | view.isOpaque = false
125 | view.backgroundColor = .clear
126 |
127 | installBackgroundAndVibrancy()
128 | installSearchBar()
129 | installSuggestionsController()
130 | }
131 |
132 | private func installBackgroundAndVibrancy() {
133 | backgroundView.frame = view.bounds
134 | view.addSubview(backgroundView)
135 |
136 | vibrancyView.frame = view.bounds
137 | backgroundView.contentView.addSubview(vibrancyView)
138 | }
139 |
140 | private func installSearchBar() {
141 | vibrancyView.contentView.addSubview(searchBar)
142 | searchBar.topAnchor.constraint(equalTo: vibrancyView.contentView.topAnchor, constant: Metrics.extraPadding * 2).isActive = true
143 | searchBar.leadingAnchor.constraint(equalTo: vibrancyView.contentView.leadingAnchor, constant: Metrics.padding / 2).isActive = true
144 | searchBar.trailingAnchor.constraint(equalTo: vibrancyView.contentView.trailingAnchor, constant: -Metrics.padding / 2).isActive = true
145 | }
146 |
147 | private func installSuggestionsController() {
148 | addChildViewController(suggestionsController)
149 |
150 | vibrancyView.contentView.addSubview(suggestionsController.view)
151 |
152 | suggestionsController.view.leadingAnchor.constraint(equalTo: vibrancyView.contentView.leadingAnchor, constant: Metrics.padding).isActive = true
153 | suggestionsController.view.trailingAnchor.constraint(equalTo: vibrancyView.contentView.trailingAnchor, constant: -Metrics.padding).isActive = true
154 | suggestionsController.view.topAnchor.constraint(equalTo: searchBar.bottomAnchor, constant: Metrics.extraPadding).isActive = true
155 |
156 | suggestionsController.didMove(toParentViewController: self)
157 | }
158 |
159 | private func saveSearchToRecents(with term: String) {
160 | do {
161 | try syncEngine.registerSearchHistory(for: term)
162 | } catch {
163 | os_log("Failed to register recent search: %{public}@", log: self.log, type: .error, String(describing: error))
164 | }
165 | }
166 |
167 | private var suggestionsDisposeBag = DisposeBag()
168 |
169 | private func bindSearchSuggestions() {
170 | suggestionsDisposeBag = DisposeBag()
171 |
172 | syncEngine.fetchRandomCategories().bind(to: suggestionsController.categories).disposed(by: suggestionsDisposeBag)
173 | syncEngine.fetchRecentSearches(with: 16).bind(to: suggestionsController.recents).disposed(by: suggestionsDisposeBag)
174 | }
175 |
176 | override func viewWillAppear(_ animated: Bool) {
177 | super.viewWillAppear(animated)
178 |
179 | searchBar.text = nil
180 | bindSearchSuggestions()
181 | }
182 |
183 | override var preferredStatusBarStyle: UIStatusBarStyle {
184 | return .lightContent
185 | }
186 |
187 | @objc private func backgroundTapped() {
188 | delegate?.searchViewControllerWantsToBeDismissed(self)
189 | }
190 |
191 | // MARK: - Transition Support
192 |
193 | func configureWithDismissedState() {
194 | backgroundView.effect = nil
195 | searchBar.alpha = 0
196 | suggestionsController.view.alpha = 0
197 | vibrancyView.layer.transform = CATransform3DMakeTranslation(0, -100, 0)
198 | view.endEditing(true)
199 | }
200 |
201 | func configureWithPresentedState() {
202 | backgroundView.effect = blurEffect
203 | searchBar.alpha = 1
204 | suggestionsController.view.alpha = 1
205 | vibrancyView.layer.transform = CATransform3DIdentity
206 | }
207 |
208 | }
209 |
210 | extension SearchViewController: UISearchBarDelegate {
211 |
212 | func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
213 | guard let term = searchBar.text, term.count > 3 else { return }
214 |
215 | saveSearchToRecents(with: term)
216 |
217 | delegate?.searchViewController(self, didSearchForTerm: term)
218 | }
219 |
220 | func searchBarShouldBeginEditing(_ searchBar: UISearchBar) -> Bool {
221 | return true
222 | }
223 |
224 | func searchBarShouldEndEditing(_ searchBar: UISearchBar) -> Bool {
225 | return true
226 | }
227 |
228 | }
229 |
230 | extension SearchViewController: SearchSuggestionsViewControllerDelegate {
231 |
232 | func searchSuggestionsViewController(_ controller: SearchSuggestionsViewController, didSelectSuggestionWithTerm term: String) {
233 | searchBar.text = term
234 | delegate?.searchViewController(self, didSearchForTerm: term)
235 | }
236 |
237 | }
238 |
--------------------------------------------------------------------------------
/Chuck/Controllers/Home/ListJokesViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ListJokesViewController.swift
3 | // Chuck
4 | //
5 | // Created by Guilherme Rambo on 22/05/18.
6 | // Copyright © 2018 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import RxSwift
11 | import RxCocoa
12 | import RxDataSources
13 | import ChuckCore
14 |
15 | private struct JokeSectionModel: AnimatableSectionModelType {
16 |
17 | static let cellIdentifier = "jokeCell"
18 |
19 | var identity: String {
20 | return "Facts"
21 | }
22 |
23 | typealias Identity = String
24 | typealias Item = JokeViewModel
25 |
26 | var items: [JokeViewModel]
27 |
28 | init(original: JokeSectionModel, items: [JokeViewModel]) {
29 | self = original
30 | self.items = items
31 | }
32 |
33 | init(items: [JokeViewModel]) {
34 | self.items = items
35 | }
36 |
37 | }
38 |
39 | protocol ListJokesViewControllerDelegate: class {
40 | func listJokesViewControllerDidSelectSearch(_ controller: ListJokesViewController)
41 | func listJokesViewController(_ controller: ListJokesViewController, didSelectShareWithViewModel viewModel: JokeViewModel)
42 | }
43 |
44 | final class ListJokesViewController: UIViewController {
45 |
46 | weak var delegate: ListJokesViewControllerDelegate?
47 | weak var scrollingDelegate: UIScrollViewDelegate?
48 |
49 | lazy var isLoading = Variable(false)
50 |
51 | lazy var jokes = Variable<[JokeViewModel]>([])
52 |
53 | private let disposeBag = DisposeBag()
54 |
55 | private lazy var titleLabel: UILabel = {
56 | let label = UILabel()
57 |
58 | label.translatesAutoresizingMaskIntoConstraints = false
59 | label.font = .largeTitle
60 | label.text = "Facts"
61 |
62 | return label
63 | }()
64 |
65 | lazy var offlineBadge: BadgeView = {
66 | let badge = BadgeView()
67 |
68 | badge.style = .small
69 | badge.title = "OFFLINE"
70 | badge.translatesAutoresizingMaskIntoConstraints = false
71 | badge.isHidden = true
72 | badge.backgroundColor = .error
73 |
74 | return badge
75 | }()
76 |
77 | private lazy var searchButton: UIButton = {
78 | let button = UIButton(type: .system)
79 |
80 | button.translatesAutoresizingMaskIntoConstraints = false
81 | button.accessibilityLabel = "Search"
82 | button.setImage(#imageLiteral(resourceName: "search"), for: .normal)
83 | button.addTarget(self, action: #selector(searchTapped), for: .touchUpInside)
84 | button.tintColor = .primary
85 | button.uiTestingLabel = .searchButton
86 |
87 | return button
88 | }()
89 |
90 | private lazy var activityIndicator: UIActivityIndicatorView = {
91 | let indicator = UIActivityIndicatorView(activityIndicatorStyle: .gray)
92 |
93 | indicator.translatesAutoresizingMaskIntoConstraints = false
94 | indicator.hidesWhenStopped = true
95 | indicator.uiTestingLabel = .loadingIndicator
96 |
97 | return indicator
98 | }()
99 |
100 | private var effectiveHeaderHeight: CGFloat {
101 | return Metrics.headerHeight + view.safeAreaInsets.top
102 | }
103 |
104 | private lazy var headerHeightConstraint: NSLayoutConstraint = {
105 | return headerView.heightAnchor.constraint(equalToConstant: effectiveHeaderHeight)
106 | }()
107 |
108 | private lazy var headerView: UIVisualEffectView = {
109 | let header = UIVisualEffectView(effect: UIBlurEffect(style: .extraLight))
110 |
111 | header.translatesAutoresizingMaskIntoConstraints = false
112 |
113 | return header
114 | }()
115 |
116 | private lazy var tableView: UITableView = {
117 | let table = UITableView(frame: .zero, style: .plain)
118 |
119 | table.uiTestingLabel = .jokesList
120 | table.translatesAutoresizingMaskIntoConstraints = false
121 | table.separatorColor = .clear
122 | table.estimatedRowHeight = 172
123 | table.rowHeight = UITableViewAutomaticDimension
124 | table.delegate = self
125 |
126 | return table
127 | }()
128 |
129 | override func viewDidLoad() {
130 | super.viewDidLoad()
131 |
132 | view.backgroundColor = .white
133 |
134 | installTableView()
135 | installHeader()
136 | installTitleLabel()
137 | installOfflineBadge()
138 | installSearchButton()
139 | installActivityIndicator()
140 | }
141 |
142 | private func installTitleLabel() {
143 | view.addSubview(titleLabel)
144 | titleLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: Metrics.padding).isActive = true
145 | titleLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: Metrics.extraPadding).isActive = true
146 | }
147 |
148 | private func installOfflineBadge() {
149 | view.addSubview(offlineBadge)
150 | offlineBadge.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
151 | offlineBadge.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor).isActive = true
152 | }
153 |
154 | private func installSearchButton() {
155 | view.addSubview(searchButton)
156 | searchButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -Metrics.padding).isActive = true
157 | searchButton.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor).isActive = true
158 | }
159 |
160 | private func installActivityIndicator() {
161 | view.addSubview(activityIndicator)
162 | activityIndicator.centerXAnchor.constraint(equalTo: searchButton.centerXAnchor).isActive = true
163 | activityIndicator.centerYAnchor.constraint(equalTo: searchButton.centerYAnchor).isActive = true
164 |
165 | bindActivityIndicator()
166 | }
167 |
168 | @objc private func searchTapped() {
169 | delegate?.listJokesViewControllerDidSelectSearch(self)
170 | }
171 |
172 | private func bindActivityIndicator() {
173 | isLoading.asObservable().bind(to: activityIndicator.rx.isAnimating).disposed(by: disposeBag)
174 | isLoading.asObservable().bind(to: searchButton.rx.isHidden).disposed(by: disposeBag)
175 | }
176 |
177 | // MARK: - Header
178 |
179 | private func installHeader() {
180 | view.addSubview(headerView)
181 | headerHeightConstraint.isActive = true
182 | headerView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
183 | headerView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
184 | headerView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
185 | }
186 |
187 | override func viewSafeAreaInsetsDidChange() {
188 | super.viewSafeAreaInsetsDidChange()
189 |
190 | headerHeightConstraint.constant = effectiveHeaderHeight
191 | }
192 |
193 | // MARK: - Table view
194 |
195 | private func installTableView() {
196 | view.addSubview(tableView)
197 |
198 | tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
199 | tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
200 | tableView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
201 | tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
202 |
203 | tableView.contentInset = UIEdgeInsets(top: Metrics.headerHeight, left: 0, bottom: 0, right: 0)
204 | tableView.scrollIndicatorInsets = tableView.contentInset
205 |
206 | bindTableView()
207 | }
208 |
209 | private func bindTableView() {
210 | tableView.register(JokeTableViewCell.self, forCellReuseIdentifier: JokeSectionModel.cellIdentifier)
211 |
212 | let dataSource = RxTableViewSectionedAnimatedDataSource(configureCell: { _, tableView, _, item in
213 | let cell = tableView.dequeueReusableCell(withIdentifier: JokeSectionModel.cellIdentifier) as! JokeTableViewCell
214 |
215 | cell.viewModel = item
216 |
217 | cell.didSelectShare = { [weak self] in
218 | guard let `self` = self else { return }
219 | self.delegate?.listJokesViewController(self, didSelectShareWithViewModel: item)
220 | }
221 |
222 | return cell
223 | })
224 |
225 | let sections = jokes.asObservable().map({ [JokeSectionModel(items: $0)] })
226 | sections.bind(to: tableView.rx.items(dataSource: dataSource)).disposed(by: disposeBag)
227 | }
228 |
229 | }
230 |
231 | extension ListJokesViewController: UITableViewDelegate {
232 |
233 | func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool {
234 | return false
235 | }
236 |
237 | func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
238 | scrollingDelegate?.scrollViewWillBeginDragging?(scrollView)
239 | }
240 |
241 | func scrollViewDidScroll(_ scrollView: UIScrollView) {
242 | scrollingDelegate?.scrollViewDidScroll?(scrollView)
243 | }
244 |
245 | func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) {
246 | scrollingDelegate?.scrollViewWillEndDragging?(scrollView, withVelocity: velocity, targetContentOffset: targetContentOffset)
247 | }
248 |
249 | }
250 |
--------------------------------------------------------------------------------
/Chuck/Controllers/AppFlowController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppFlowController.swift
3 | // Chuck
4 | //
5 | // Created by Guilherme Rambo on 22/05/18.
6 | // Copyright © 2018 Guilherme Rambo. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import RxSwift
11 | import RxCocoa
12 | import ChuckCore
13 |
14 | final class AppFlowController: UIViewController {
15 |
16 | enum ListState {
17 | case empty
18 | case jokes([JokeViewModel])
19 | }
20 |
21 | lazy var isOffline = Variable(false)
22 |
23 | private let disposeBag = DisposeBag()
24 |
25 | let syncEngine: SyncEngine
26 |
27 | init(syncEngine: SyncEngine) {
28 | self.syncEngine = syncEngine
29 |
30 | super.init(nibName: nil, bundle: nil)
31 | }
32 |
33 | required init?(coder aDecoder: NSCoder) {
34 | fatalError("Not supported")
35 | }
36 |
37 | private lazy var listJokesController: ListJokesViewController = {
38 | let controller = ListJokesViewController()
39 |
40 | controller.delegate = self
41 | controller.scrollingDelegate = searchPresenter
42 |
43 | return controller
44 | }()
45 |
46 | private lazy var searchController: SearchViewController = {
47 | let controller = SearchViewController(syncEngine: syncEngine)
48 |
49 | controller.delegate = self
50 |
51 | return controller
52 | }()
53 |
54 | private lazy var state = Variable(.empty)
55 |
56 | private lazy var mainNavigationController: UINavigationController = {
57 | let controller = UINavigationController(rootViewController: listJokesController)
58 |
59 | controller.isNavigationBarHidden = true
60 |
61 | return controller
62 | }()
63 |
64 | override func viewDidLoad() {
65 | super.viewDidLoad()
66 |
67 | installChild(mainNavigationController)
68 |
69 | // Bind list initally to a selection of random jokes already cached locally
70 | bindListState(with: syncEngine.fetchRandomJokes(with: 20))
71 |
72 | // Show offline badge when in offline mode
73 | isOffline.asObservable().map({ !$0 }).bind(to: listJokesController.offlineBadge.rx.isHidden).disposed(by: disposeBag)
74 | }
75 |
76 | override func viewDidAppear(_ animated: Bool) {
77 | super.viewDidAppear(animated)
78 |
79 | searchPresenter.isPresentingSearch = false
80 | }
81 |
82 | private var listStateDisposeBag = DisposeBag()
83 |
84 | private func unbindListState() {
85 | listStateDisposeBag = DisposeBag()
86 | }
87 |
88 | private func bindListState(with observable: Observable<[JokeViewModel]>) {
89 | unbindListState()
90 |
91 | // Maps the current list observable to either an empty list state or a list full of jokes
92 | let stateObservable = observable.map { jokes -> ListState in
93 | if jokes.count > 0 {
94 | return ListState.jokes(jokes)
95 | } else {
96 | return ListState.empty
97 | }
98 | }.do(onNext: { [weak self] _ in
99 | self?.listJokesController.isLoading.value = false
100 | }, onSubscribed: { [weak self] in
101 | self?.listJokesController.isLoading.value = true
102 | })
103 |
104 | // Binds the current state observable to the state variable of the flow controller for others to observe
105 | stateObservable.do(onError: { [weak self] error in
106 | self?.showErrorState(with: error)
107 | }).bind(to: state).disposed(by: listStateDisposeBag)
108 |
109 | // Binds the current state to the list controller or shows the empty/error state if necessary
110 | state.asObservable().subscribe(onNext: { [weak self] currentState in
111 | switch currentState {
112 | case .empty:
113 | self?.showEmptyState(with: Messages.firstLaunchEmtpy, actionTitle: "SEARCH FACTS")
114 | case .jokes(let jokes):
115 | self?.hideEmptyState()
116 | self?.listJokesController.jokes.value = jokes
117 | }
118 | }, onError: { [weak self] error in
119 | self?.showErrorState(with: error)
120 | }).disposed(by: listStateDisposeBag)
121 | }
122 |
123 | // MARK: - States
124 |
125 | private func showErrorState(with error: Error) {
126 | listJokesController.isLoading.value = false
127 | hideEmptyState()
128 | }
129 |
130 | private lazy var emptyViewController: EmptyViewController = {
131 | let controller = EmptyViewController()
132 |
133 | controller.delegate = self
134 |
135 | return controller
136 | }()
137 |
138 | private func showEmptyState(with message: String, actionTitle: String) {
139 | guard listJokesController.isLoading.value == false else { return }
140 |
141 | if emptyViewController.view.superview == nil {
142 | addChildViewController(emptyViewController)
143 | emptyViewController.view.translatesAutoresizingMaskIntoConstraints = false
144 | view.addSubview(emptyViewController.view)
145 |
146 | emptyViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
147 | emptyViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
148 | emptyViewController.view.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
149 |
150 | emptyViewController.didMove(toParentViewController: self)
151 | }
152 |
153 | emptyViewController.message = message
154 | emptyViewController.actionTitle = actionTitle
155 |
156 | emptyViewController.view.isHidden = false
157 | listJokesController.jokes.value = []
158 | }
159 |
160 | private func hideEmptyState() {
161 | emptyViewController.view.isHidden = true
162 | }
163 |
164 | // MARK: - Actions
165 |
166 | func shareJoke(with viewModel: JokeViewModel) {
167 | let activityController = UIActivityViewController(activityItems: [viewModel.url], applicationActivities: nil)
168 | present(activityController, animated: true, completion: nil)
169 | }
170 |
171 | func fetchRandomJoke() {
172 | prepareNotificationHaptic()
173 |
174 | let randomJokeObservable = syncEngine.randomJoke().map({[$0]}).do(onNext: { [weak self] _ in
175 | self?.performSuccessHaptic()
176 | })
177 |
178 | bindListState(with: randomJokeObservable)
179 | }
180 |
181 | func fetchSearchResults(with term: String) {
182 | unbindListState()
183 | hideEmptyState()
184 |
185 | listJokesController.isLoading.value = true
186 |
187 | syncEngine.syncSearchResults(with: term).subscribe { [weak self] event in
188 | switch event {
189 | case .completed:
190 | self?.showEmptySearchResultsStateIfNeeded()
191 | default:
192 | break
193 | }
194 | self?.listJokesController.isLoading.value = false
195 | }.disposed(by: listStateDisposeBag)
196 |
197 | syncEngine.fetchSearchResults(with: term).do(onNext: { [weak self] _ in
198 | self?.showEmptySearchResultsStateIfNeeded()
199 | }).bind(to: listJokesController.jokes).disposed(by: listStateDisposeBag)
200 | }
201 |
202 | private func showEmptySearchResultsStateIfNeeded() {
203 | if listJokesController.jokes.value.count > 0 {
204 | hideEmptyState()
205 | } else {
206 | let message = isOffline.value ? Messages.searchResultsEmtpyOffline : Messages.searchResultsEmtpy
207 | showEmptyState(with: message, actionTitle: "")
208 | }
209 | }
210 |
211 | // MARK: - Interaction
212 |
213 | private lazy var impactGenerator: UIImpactFeedbackGenerator = {
214 | return UIImpactFeedbackGenerator(style: .medium)
215 | }()
216 |
217 | private lazy var notificationGenerator = UINotificationFeedbackGenerator()
218 |
219 | private func prepareNotificationHaptic() {
220 | notificationGenerator.prepare()
221 | }
222 |
223 | private func performSuccessHaptic() {
224 | notificationGenerator.notificationOccurred(.success)
225 | }
226 |
227 | override func motionBegan(_ motion: UIEventSubtype, with event: UIEvent?) {
228 | guard motion == .motionShake else {
229 | super.motionBegan(motion, with: event)
230 | return
231 | }
232 |
233 | impactGenerator.prepare()
234 | }
235 |
236 | override func motionEnded(_ motion: UIEventSubtype, with event: UIEvent?) {
237 | guard motion == .motionShake else {
238 | super.motionEnded(motion, with: event)
239 | return
240 | }
241 |
242 | impactGenerator.impactOccurred()
243 | fetchRandomJoke()
244 | }
245 |
246 | // MARK: - Search Presentation
247 |
248 | private lazy var searchPresenter: SearchPresentationDriver = {
249 | return SearchPresentationDriver(searchController: searchController, presenter: self)
250 | }()
251 |
252 | }
253 |
254 | extension AppFlowController: ListJokesViewControllerDelegate {
255 |
256 | func listJokesViewControllerDidSelectSearch(_ controller: ListJokesViewController) {
257 | searchPresenter.presentSearch()
258 | }
259 |
260 | func listJokesViewController(_ controller: ListJokesViewController, didSelectShareWithViewModel viewModel: JokeViewModel) {
261 | shareJoke(with: viewModel)
262 | }
263 |
264 | }
265 |
266 | extension AppFlowController: EmptyViewControllerDelegate {
267 |
268 | func emptyViewControllerDidSelectSearch(_ controller: EmptyViewController) {
269 | searchPresenter.presentSearch()
270 | }
271 |
272 | }
273 |
274 | extension AppFlowController: SearchViewControllerDelegate {
275 |
276 | func searchViewControllerWantsToBeDismissed(_ controller: SearchViewController) {
277 | dismiss(animated: true, completion: nil)
278 | }
279 |
280 | func searchViewController(_ controller: SearchViewController, didSearchForTerm term: String) {
281 | dismiss(animated: true, completion: nil)
282 | fetchSearchResults(with: term)
283 | }
284 |
285 | }
286 |
--------------------------------------------------------------------------------