├── 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 | ![search](./Screenshots/2-search.png) 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 | ![search](./Screenshots/1-home.png) 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 | ![search](./Screenshots/3-share.png) 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 | --------------------------------------------------------------------------------