├── .gitignore ├── .swift-version ├── .swiftlint.yml ├── Podfile ├── Podfile.lock ├── README.md ├── Recipes.xcodeproj └── project.pbxproj ├── RecipesTests ├── Fixture │ ├── recipes.json │ └── singleRecipe.json ├── Info.plist ├── Library │ ├── Model │ │ └── RecipeTests.swift │ ├── Service │ │ ├── CacheServiceTests.swift │ │ └── RecipesService.swift │ └── Utils │ │ └── DebouncerTests.swift └── Mock │ └── MockNetworkService.swift ├── RecipesUITests ├── Info.plist └── RecipesUITests.swift ├── Resource ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ ├── icon_1024@1x.png │ │ ├── icon_20@1x.png │ │ ├── icon_20@2x.png │ │ ├── icon_20@3x.png │ │ ├── icon_29@1x.png │ │ ├── icon_29@2x.png │ │ ├── icon_29@3x.png │ │ ├── icon_40@1x.png │ │ ├── icon_40@2x.png │ │ ├── icon_40@3x.png │ │ ├── icon_60@2x.png │ │ ├── icon_60@3x.png │ │ ├── icon_76@1x.png │ │ ├── icon_76@2x.png │ │ └── icon_83.5@2x.png │ ├── Contents.json │ ├── launchImage.imageset │ │ ├── Contents.json │ │ └── Spoon.jpg │ ├── notFound.imageset │ │ ├── Contents.json │ │ └── notFound.png │ └── recipePlaceholder.imageset │ │ ├── Contents.json │ │ └── recipePlaceholder.jpg ├── Base.lproj │ └── LaunchScreen.storyboard └── Info.plist ├── Screenshots ├── AppIcon.png ├── AppStore.png ├── Detail.png ├── Home.png ├── Insomnia.png ├── LaunchScreen.png ├── MARK.png ├── MainGuard.png ├── Measurement.png ├── Project.png └── SwiftLint.png └── Source ├── Feature ├── Detail │ ├── RecipeDetailView.swift │ └── RecipeDetailViewController.swift ├── Home │ └── HomeViewController.swift ├── List │ ├── RecipeCell.swift │ └── RecipeListViewController.swift └── Search │ └── SearchComponent.swift └── Library ├── Adapter └── Adapter.swift ├── App ├── AppConfig.swift ├── AppDelegate.swift └── Color.swift ├── Base └── BaseController.swift ├── Constants └── R.generated.swift ├── Extensions ├── NSLayoutConstraint+Extensions.swift ├── UICollectionView+Extensions.swift ├── UIColor+Extensions.swift ├── UIImageView+Extensions.swift ├── UIView+Extensions.swift └── UIViewController+Extensions.swift ├── Flow ├── AppFlowController.swift └── RecipeFlowController.swift ├── Model └── Recipe.swift ├── Networking ├── Networking.swift └── Resource.swift ├── Service ├── CacheService.swift ├── ImageService.swift ├── NetworkService.swift └── RecipesService.swift ├── Utils └── Debouncer.swift └── View ├── EmptyView.swift └── ScrollableView.swift /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | .DS_Store 3 | */build/* 4 | *.pbxuser 5 | !default.pbxuser 6 | *.mode1v3 7 | !default.mode1v3 8 | *.mode2v3 9 | !default.mode2v3 10 | *.perspectivev3 11 | !default.perspectivev3 12 | xcuserdata 13 | profile 14 | *.moved-aside 15 | DerivedData 16 | .idea/ 17 | *.hmap 18 | *.xccheckout 19 | *.xcworkspace 20 | !default.xcworkspace 21 | 22 | #CocoaPods 23 | Pods 24 | -------------------------------------------------------------------------------- /.swift-version: -------------------------------------------------------------------------------- 1 | 4.1 2 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | disabled_rules: 2 | - nesting 3 | - closure_parameter_position 4 | - type_name 5 | - for_where 6 | - class_delegate_protocol 7 | opt_in_rules: # some rules are only opt-in 8 | - empty_count 9 | # Find all the available rules by running: 10 | # swiftlint rules 11 | included: # paths to include during linting. `--path` is ignored if present. 12 | - Source 13 | excluded: # paths to ignore during linting. Takes precedence over `included`. 14 | - Carthage 15 | - Pods 16 | - Source/Library/Constants/R.generated.swift 17 | 18 | # configurable rules can be customized from this configuration file 19 | # binary rules can set their severity level 20 | force_cast: warning # implicitly 21 | force_try: 22 | severity: warning # explicitly 23 | # rules that have both warning and error levels, can set just the warning level 24 | # implicitly 25 | line_length: 200 26 | function_body_length: 27 | - 150 #warning 28 | # they can set both implicitly with an array 29 | type_body_length: 30 | - 300 # warning 31 | - 400 # error 32 | # or they can set both explicitly 33 | file_length: 34 | warning: 500 35 | error: 1200 36 | # naming rules can set warnings/errors for min_length and max_length 37 | # additionally they can set excluded names 38 | type_name: 39 | min_length: 2 # only warning 40 | max_length: # warning and error 41 | warning: 40 42 | error: 50 43 | excluded: iPhone # excluded via string 44 | variable_name: 45 | min_length: # only min_length 46 | error: 2 # only error 47 | excluded: # excluded via string array 48 | - me 49 | - x 50 | - y 51 | - id 52 | - URL 53 | - GlobalAPIKey 54 | reporter: "xcode" # reporter type (xcode, json, csv, checkstyle) 55 | -------------------------------------------------------------------------------- /Podfile: -------------------------------------------------------------------------------- 1 | source 'https://github.com/CocoaPods/Specs.git' 2 | platform :ios, '9.0' 3 | 4 | target 'Recipes' do 5 | 6 | use_frameworks! 7 | 8 | pod 'SwiftLint', '~> 0.25' 9 | pod 'SwiftHash', '~> 2.0' 10 | pod 'R.swift', '~> 4.0' 11 | 12 | # Pods for Recipes 13 | 14 | target 'RecipesTests' do 15 | inherit! :search_paths 16 | # Pods for testing 17 | end 18 | 19 | target 'RecipesUITests' do 20 | inherit! :search_paths 21 | # Pods for testing 22 | end 23 | 24 | end 25 | -------------------------------------------------------------------------------- /Podfile.lock: -------------------------------------------------------------------------------- 1 | PODS: 2 | - R.swift (4.0.0): 3 | - R.swift.Library (~> 4.0.0) 4 | - R.swift.Library (4.0.0) 5 | - SwiftHash (2.0.1) 6 | - SwiftLint (0.25.0) 7 | 8 | DEPENDENCIES: 9 | - R.swift (~> 4.0) 10 | - SwiftHash (~> 2.0) 11 | - SwiftLint (~> 0.25) 12 | 13 | SPEC CHECKSUMS: 14 | R.swift: d6a5ec2f55a8441dc0ed9f1f8b37d7d11ae85c66 15 | R.swift.Library: c3af34921024333546e23b70e70d0b4e0cffca75 16 | SwiftHash: c805de5a434eacfa0dac7210745a50c55d7fe33d 17 | SwiftLint: e14651157288e9e01d6e1a71db7014fb5744a8ea 18 | 19 | PODFILE CHECKSUM: 50e70ce7458761321dd026beefe340c38b858fe6 20 | 21 | COCOAPODS: 1.4.0 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Recipes App 2 | == 3 | 4 | ❤️ Support my apps ❤️ 5 | 6 | - [Push Hero - pure Swift native macOS application to test push notifications](https://onmyway133.com/pushhero) 7 | - [PastePal - Pasteboard, note and shortcut manager](https://onmyway133.com/pastepal) 8 | - [Quick Check - smart todo manager](https://onmyway133.com/quickcheck) 9 | - [Alias - App and file shortcut manager](https://onmyway133.com/alias) 10 | - [My other apps](https://onmyway133.com/apps/) 11 | 12 | ❤️❤️😇😍🤘❤️❤️ 13 | 14 | ## Description 15 | 16 | - An app that showcases many recipes together with their detail information 17 | - Contain lots of good practices for structuring iOS app and handle dependencies 18 | - Support iOS 9+ 19 | - Use Xcode 9.3, Swift 4.0 20 | - Please read [Learn iOS best practices by building a simple recipes app](https://medium.freecodecamp.org/learn-ios-best-practices-by-building-a-simple-recipes-app-9bcbce4d10d) 21 | 22 |
23 | 24 | 25 |
26 | 27 | ## Credit 28 | 29 | - Launch image is from http://desertrosemediapa.com/ 30 | - App icon is from https://www.flaticon.com/free-icon/rice_168559 31 | 32 | ## Licence 33 | 34 | This project is released under the MIT license. See LICENSE.md. 35 | -------------------------------------------------------------------------------- /Recipes.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 48; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 2DB1405B29CF577A4C5AC6A0 /* Pods_RecipesUITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CAFF210BA0C48F271B66FF16 /* Pods_RecipesUITests.framework */; }; 11 | 456A010A541FE67397DD3F07 /* Pods_RecipesTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C603ABB2DDC033874DEEFEB5 /* Pods_RecipesTests.framework */; }; 12 | D2144AA2204212DD005CD925 /* UIColor+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2144AA1204212DD005CD925 /* UIColor+Extensions.swift */; }; 13 | D2144AA420421328005CD925 /* NSLayoutConstraint+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2144AA320421328005CD925 /* NSLayoutConstraint+Extensions.swift */; }; 14 | D2144AAA2042146D005CD925 /* RecipeDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2144AA92042146D005CD925 /* RecipeDetailViewController.swift */; }; 15 | D2144AAD204214D4005CD925 /* RecipeListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2144AAC204214D4005CD925 /* RecipeListViewController.swift */; }; 16 | D2144AB3204216CB005CD925 /* AppFlowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2144AB2204216CB005CD925 /* AppFlowController.swift */; }; 17 | D2144AB5204216FC005CD925 /* UIViewController+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2144AB4204216FC005CD925 /* UIViewController+Extensions.swift */; }; 18 | D2144AB72042173F005CD925 /* RecipeFlowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2144AB62042173F005CD925 /* RecipeFlowController.swift */; }; 19 | D2144ABE20421C16005CD925 /* Adapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2144ABD20421C16005CD925 /* Adapter.swift */; }; 20 | D2144AC020421E4C005CD925 /* RecipeCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2144ABF20421E4C005CD925 /* RecipeCell.swift */; }; 21 | D2144AC42042267A005CD925 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2144AC32042267A005CD925 /* AppDelegate.swift */; }; 22 | D2144AC620422686005CD925 /* AppConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2144AC520422686005CD925 /* AppConfig.swift */; }; 23 | D2144AC8204226F6005CD925 /* UICollectionView+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2144AC7204226F6005CD925 /* UICollectionView+Extensions.swift */; }; 24 | D2144ACA20422E19005CD925 /* Color.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2144AC920422E19005CD925 /* Color.swift */; }; 25 | D2144ACD20422EA5005CD925 /* ScrollableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2144ACC20422EA5005CD925 /* ScrollableView.swift */; }; 26 | D2144ACF20422EAE005CD925 /* BaseController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2144ACE20422EAE005CD925 /* BaseController.swift */; }; 27 | D2144AD120422ED5005CD925 /* RecipeDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2144AD020422ED5005CD925 /* RecipeDetailView.swift */; }; 28 | D2144AD32042F218005CD925 /* UIView+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2144AD22042F218005CD925 /* UIView+Extensions.swift */; }; 29 | D2144ADA2042FA12005CD925 /* SearchComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2144AD92042FA12005CD925 /* SearchComponent.swift */; }; 30 | D2144ADD2042FD1E005CD925 /* HomeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2144ADC2042FD1E005CD925 /* HomeViewController.swift */; }; 31 | D21D3DC82041C22F00EF97BC /* NetworkService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D21D3DC72041C22F00EF97BC /* NetworkService.swift */; }; 32 | D21D3DCA2041CD4600EF97BC /* RecipesService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D21D3DC92041CD4600EF97BC /* RecipesService.swift */; }; 33 | D21D3DCC2041D59B00EF97BC /* CacheService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D21D3DCB2041D59B00EF97BC /* CacheService.swift */; }; 34 | D21D3DCE2041D5C200EF97BC /* ImageService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D21D3DCD2041D5C200EF97BC /* ImageService.swift */; }; 35 | D21D3DD02041D5DC00EF97BC /* UIImageView+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D21D3DCF2041D5DC00EF97BC /* UIImageView+Extensions.swift */; }; 36 | D2261FC2204163AE0072956E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D2261FBC204163AE0072956E /* Assets.xcassets */; }; 37 | D2261FC3204163AE0072956E /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D2261FBE204163AE0072956E /* LaunchScreen.storyboard */; }; 38 | D2261FCB204171690072956E /* Recipe.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2261FCA204171690072956E /* Recipe.swift */; }; 39 | D28E1F83EB9D0A42FEAE58E6 /* Pods_Recipes.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1D0989C260791A5450C452E1 /* Pods_Recipes.framework */; }; 40 | D29C8B752041622A00F6DFC0 /* RecipesUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D29C8B742041622A00F6DFC0 /* RecipesUITests.swift */; }; 41 | D2D14552204186B600B31280 /* RecipeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2D14551204186B600B31280 /* RecipeTests.swift */; }; 42 | D2D5ED3D2043140200BF46BC /* R.generated.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2D5ED3C2043140200BF46BC /* R.generated.swift */; }; 43 | D2D5ED3F2043178800BF46BC /* EmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2D5ED3E2043178800BF46BC /* EmptyView.swift */; }; 44 | D2D5ED4220431B7B00BF46BC /* CacheServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2D5ED4120431B7B00BF46BC /* CacheServiceTests.swift */; }; 45 | D2D5ED4820431FB900BF46BC /* Debouncer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2D5ED4720431FB900BF46BC /* Debouncer.swift */; }; 46 | D2D5ED4B2043201F00BF46BC /* DebouncerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2D5ED4A2043201F00BF46BC /* DebouncerTests.swift */; }; 47 | D2D5ED4E2043234000BF46BC /* Networking.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2D5ED4D2043234000BF46BC /* Networking.swift */; }; 48 | D2D5ED502043236E00BF46BC /* Resource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2D5ED4F2043236E00BF46BC /* Resource.swift */; }; 49 | D2D5ED522043241C00BF46BC /* RecipesService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2D5ED512043241C00BF46BC /* RecipesService.swift */; }; 50 | D2D5ED562043244900BF46BC /* recipes.json in Resources */ = {isa = PBXBuildFile; fileRef = D2D5ED552043244900BF46BC /* recipes.json */; }; 51 | D2D5ED582043245E00BF46BC /* singleRecipe.json in Resources */ = {isa = PBXBuildFile; fileRef = D2D5ED572043245E00BF46BC /* singleRecipe.json */; }; 52 | D2D5ED5A2043246700BF46BC /* MockNetworkService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2D5ED592043246700BF46BC /* MockNetworkService.swift */; }; 53 | /* End PBXBuildFile section */ 54 | 55 | /* Begin PBXContainerItemProxy section */ 56 | D29C8B662041622900F6DFC0 /* PBXContainerItemProxy */ = { 57 | isa = PBXContainerItemProxy; 58 | containerPortal = D29C8B492041622900F6DFC0 /* Project object */; 59 | proxyType = 1; 60 | remoteGlobalIDString = D29C8B502041622900F6DFC0; 61 | remoteInfo = Recipes; 62 | }; 63 | D29C8B712041622A00F6DFC0 /* PBXContainerItemProxy */ = { 64 | isa = PBXContainerItemProxy; 65 | containerPortal = D29C8B492041622900F6DFC0 /* Project object */; 66 | proxyType = 1; 67 | remoteGlobalIDString = D29C8B502041622900F6DFC0; 68 | remoteInfo = Recipes; 69 | }; 70 | /* End PBXContainerItemProxy section */ 71 | 72 | /* Begin PBXFileReference section */ 73 | 0B7FB436DB3ED6792BB13CF2 /* Pods-Recipes.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Recipes.release.xcconfig"; path = "Pods/Target Support Files/Pods-Recipes/Pods-Recipes.release.xcconfig"; sourceTree = ""; }; 74 | 1D0989C260791A5450C452E1 /* Pods_Recipes.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Recipes.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 75 | 3E29D2FB9B154AD6EA94130B /* Pods-RecipesUITests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RecipesUITests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-RecipesUITests/Pods-RecipesUITests.debug.xcconfig"; sourceTree = ""; }; 76 | 55CB896310501C0C274522CF /* Pods-RecipesTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RecipesTests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-RecipesTests/Pods-RecipesTests.debug.xcconfig"; sourceTree = ""; }; 77 | 67AB69A190C809D9EB64DDAE /* Pods-Recipes.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Recipes.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Recipes/Pods-Recipes.debug.xcconfig"; sourceTree = ""; }; 78 | 79900A81992AB62795E12ACA /* Pods-RecipesUITests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RecipesUITests.release.xcconfig"; path = "Pods/Target Support Files/Pods-RecipesUITests/Pods-RecipesUITests.release.xcconfig"; sourceTree = ""; }; 79 | A63E1C4979821F120A7D2DFA /* Pods-RecipesTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RecipesTests.release.xcconfig"; path = "Pods/Target Support Files/Pods-RecipesTests/Pods-RecipesTests.release.xcconfig"; sourceTree = ""; }; 80 | C603ABB2DDC033874DEEFEB5 /* Pods_RecipesTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RecipesTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 81 | CAFF210BA0C48F271B66FF16 /* Pods_RecipesUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RecipesUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 82 | D2144AA1204212DD005CD925 /* UIColor+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+Extensions.swift"; sourceTree = ""; }; 83 | D2144AA320421328005CD925 /* NSLayoutConstraint+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSLayoutConstraint+Extensions.swift"; sourceTree = ""; }; 84 | D2144AA92042146D005CD925 /* RecipeDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeDetailViewController.swift; sourceTree = ""; }; 85 | D2144AAC204214D4005CD925 /* RecipeListViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RecipeListViewController.swift; sourceTree = ""; }; 86 | D2144AB2204216CB005CD925 /* AppFlowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppFlowController.swift; sourceTree = ""; }; 87 | D2144AB4204216FC005CD925 /* UIViewController+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Extensions.swift"; sourceTree = ""; }; 88 | D2144AB62042173F005CD925 /* RecipeFlowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeFlowController.swift; sourceTree = ""; }; 89 | D2144ABD20421C16005CD925 /* Adapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Adapter.swift; sourceTree = ""; }; 90 | D2144ABF20421E4C005CD925 /* RecipeCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeCell.swift; sourceTree = ""; }; 91 | D2144AC32042267A005CD925 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 92 | D2144AC520422686005CD925 /* AppConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConfig.swift; sourceTree = ""; }; 93 | D2144AC7204226F6005CD925 /* UICollectionView+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UICollectionView+Extensions.swift"; sourceTree = ""; }; 94 | D2144AC920422E19005CD925 /* Color.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Color.swift; sourceTree = ""; }; 95 | D2144ACC20422EA5005CD925 /* ScrollableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollableView.swift; sourceTree = ""; }; 96 | D2144ACE20422EAE005CD925 /* BaseController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseController.swift; sourceTree = ""; }; 97 | D2144AD020422ED5005CD925 /* RecipeDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeDetailView.swift; sourceTree = ""; }; 98 | D2144AD22042F218005CD925 /* UIView+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Extensions.swift"; sourceTree = ""; }; 99 | D2144AD92042FA12005CD925 /* SearchComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchComponent.swift; sourceTree = ""; }; 100 | D2144ADC2042FD1E005CD925 /* HomeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewController.swift; sourceTree = ""; }; 101 | D21D3DC72041C22F00EF97BC /* NetworkService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkService.swift; sourceTree = ""; }; 102 | D21D3DC92041CD4600EF97BC /* RecipesService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipesService.swift; sourceTree = ""; }; 103 | D21D3DCB2041D59B00EF97BC /* CacheService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheService.swift; sourceTree = ""; }; 104 | D21D3DCD2041D5C200EF97BC /* ImageService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageService.swift; sourceTree = ""; }; 105 | D21D3DCF2041D5DC00EF97BC /* UIImageView+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImageView+Extensions.swift"; sourceTree = ""; }; 106 | D2261FAD204162D50072956E /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 107 | D2261FBC204163AE0072956E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 108 | D2261FBF204163AE0072956E /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = LaunchScreen.storyboard; sourceTree = ""; }; 109 | D2261FCA204171690072956E /* Recipe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Recipe.swift; sourceTree = ""; }; 110 | D29C8B512041622900F6DFC0 /* Recipes.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Recipes.app; sourceTree = BUILT_PRODUCTS_DIR; }; 111 | D29C8B652041622900F6DFC0 /* RecipesTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RecipesTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 112 | D29C8B6B2041622A00F6DFC0 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 113 | D29C8B702041622A00F6DFC0 /* RecipesUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RecipesUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 114 | D29C8B742041622A00F6DFC0 /* RecipesUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipesUITests.swift; sourceTree = ""; }; 115 | D29C8B762041622A00F6DFC0 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 116 | D2D14551204186B600B31280 /* RecipeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeTests.swift; sourceTree = ""; }; 117 | D2D5ED3C2043140200BF46BC /* R.generated.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = R.generated.swift; sourceTree = ""; }; 118 | D2D5ED3E2043178800BF46BC /* EmptyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyView.swift; sourceTree = ""; }; 119 | D2D5ED4120431B7B00BF46BC /* CacheServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheServiceTests.swift; sourceTree = ""; }; 120 | D2D5ED4720431FB900BF46BC /* Debouncer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Debouncer.swift; sourceTree = ""; }; 121 | D2D5ED4A2043201F00BF46BC /* DebouncerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DebouncerTests.swift; sourceTree = ""; }; 122 | D2D5ED4D2043234000BF46BC /* Networking.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Networking.swift; sourceTree = ""; }; 123 | D2D5ED4F2043236E00BF46BC /* Resource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Resource.swift; sourceTree = ""; }; 124 | D2D5ED512043241C00BF46BC /* RecipesService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipesService.swift; sourceTree = ""; }; 125 | D2D5ED552043244900BF46BC /* recipes.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = recipes.json; sourceTree = ""; }; 126 | D2D5ED572043245E00BF46BC /* singleRecipe.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = singleRecipe.json; sourceTree = ""; }; 127 | D2D5ED592043246700BF46BC /* MockNetworkService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNetworkService.swift; sourceTree = ""; }; 128 | /* End PBXFileReference section */ 129 | 130 | /* Begin PBXFrameworksBuildPhase section */ 131 | D29C8B4E2041622900F6DFC0 /* Frameworks */ = { 132 | isa = PBXFrameworksBuildPhase; 133 | buildActionMask = 2147483647; 134 | files = ( 135 | D28E1F83EB9D0A42FEAE58E6 /* Pods_Recipes.framework in Frameworks */, 136 | ); 137 | runOnlyForDeploymentPostprocessing = 0; 138 | }; 139 | D29C8B622041622900F6DFC0 /* Frameworks */ = { 140 | isa = PBXFrameworksBuildPhase; 141 | buildActionMask = 2147483647; 142 | files = ( 143 | 456A010A541FE67397DD3F07 /* Pods_RecipesTests.framework in Frameworks */, 144 | ); 145 | runOnlyForDeploymentPostprocessing = 0; 146 | }; 147 | D29C8B6D2041622A00F6DFC0 /* Frameworks */ = { 148 | isa = PBXFrameworksBuildPhase; 149 | buildActionMask = 2147483647; 150 | files = ( 151 | 2DB1405B29CF577A4C5AC6A0 /* Pods_RecipesUITests.framework in Frameworks */, 152 | ); 153 | runOnlyForDeploymentPostprocessing = 0; 154 | }; 155 | /* End PBXFrameworksBuildPhase section */ 156 | 157 | /* Begin PBXGroup section */ 158 | 279AB6ABA3DA7E2475179DA4 /* Frameworks */ = { 159 | isa = PBXGroup; 160 | children = ( 161 | 1D0989C260791A5450C452E1 /* Pods_Recipes.framework */, 162 | C603ABB2DDC033874DEEFEB5 /* Pods_RecipesTests.framework */, 163 | CAFF210BA0C48F271B66FF16 /* Pods_RecipesUITests.framework */, 164 | ); 165 | name = Frameworks; 166 | sourceTree = ""; 167 | }; 168 | 96E44F5BA420117C22B3E0EE /* Pods */ = { 169 | isa = PBXGroup; 170 | children = ( 171 | 67AB69A190C809D9EB64DDAE /* Pods-Recipes.debug.xcconfig */, 172 | 0B7FB436DB3ED6792BB13CF2 /* Pods-Recipes.release.xcconfig */, 173 | 55CB896310501C0C274522CF /* Pods-RecipesTests.debug.xcconfig */, 174 | A63E1C4979821F120A7D2DFA /* Pods-RecipesTests.release.xcconfig */, 175 | 3E29D2FB9B154AD6EA94130B /* Pods-RecipesUITests.debug.xcconfig */, 176 | 79900A81992AB62795E12ACA /* Pods-RecipesUITests.release.xcconfig */, 177 | ); 178 | name = Pods; 179 | sourceTree = ""; 180 | }; 181 | D2144AA62042138D005CD925 /* Detail */ = { 182 | isa = PBXGroup; 183 | children = ( 184 | D2144AA92042146D005CD925 /* RecipeDetailViewController.swift */, 185 | D2144AD020422ED5005CD925 /* RecipeDetailView.swift */, 186 | ); 187 | path = Detail; 188 | sourceTree = ""; 189 | }; 190 | D2144AAB204214D4005CD925 /* List */ = { 191 | isa = PBXGroup; 192 | children = ( 193 | D2144AAC204214D4005CD925 /* RecipeListViewController.swift */, 194 | D2144ABF20421E4C005CD925 /* RecipeCell.swift */, 195 | ); 196 | path = List; 197 | sourceTree = ""; 198 | }; 199 | D2144AB0204216C4005CD925 /* Flow */ = { 200 | isa = PBXGroup; 201 | children = ( 202 | D2144AB2204216CB005CD925 /* AppFlowController.swift */, 203 | D2144AB62042173F005CD925 /* RecipeFlowController.swift */, 204 | ); 205 | path = Flow; 206 | sourceTree = ""; 207 | }; 208 | D2144ABC20421C0F005CD925 /* Adapter */ = { 209 | isa = PBXGroup; 210 | children = ( 211 | D2144ABD20421C16005CD925 /* Adapter.swift */, 212 | ); 213 | path = Adapter; 214 | sourceTree = ""; 215 | }; 216 | D2144AC120421FD9005CD925 /* Base */ = { 217 | isa = PBXGroup; 218 | children = ( 219 | D2144ACE20422EAE005CD925 /* BaseController.swift */, 220 | ); 221 | path = Base; 222 | sourceTree = ""; 223 | }; 224 | D2144AC22042267A005CD925 /* App */ = { 225 | isa = PBXGroup; 226 | children = ( 227 | D2144AC32042267A005CD925 /* AppDelegate.swift */, 228 | D2144AC520422686005CD925 /* AppConfig.swift */, 229 | D2144AC920422E19005CD925 /* Color.swift */, 230 | ); 231 | path = App; 232 | sourceTree = ""; 233 | }; 234 | D2144ACB20422E8F005CD925 /* View */ = { 235 | isa = PBXGroup; 236 | children = ( 237 | D2144ACC20422EA5005CD925 /* ScrollableView.swift */, 238 | D2D5ED3E2043178800BF46BC /* EmptyView.swift */, 239 | ); 240 | path = View; 241 | sourceTree = ""; 242 | }; 243 | D2144AD82042F9E1005CD925 /* Search */ = { 244 | isa = PBXGroup; 245 | children = ( 246 | D2144AD92042FA12005CD925 /* SearchComponent.swift */, 247 | ); 248 | path = Search; 249 | sourceTree = ""; 250 | }; 251 | D2144ADB2042FD17005CD925 /* Home */ = { 252 | isa = PBXGroup; 253 | children = ( 254 | D2144ADC2042FD1E005CD925 /* HomeViewController.swift */, 255 | ); 256 | path = Home; 257 | sourceTree = ""; 258 | }; 259 | D2261FAC204162D50072956E /* Resource */ = { 260 | isa = PBXGroup; 261 | children = ( 262 | D2261FBC204163AE0072956E /* Assets.xcassets */, 263 | D2261FBD204163AE0072956E /* Base.lproj */, 264 | D2261FAD204162D50072956E /* Info.plist */, 265 | ); 266 | path = Resource; 267 | sourceTree = ""; 268 | }; 269 | D2261FAE204162D50072956E /* Source */ = { 270 | isa = PBXGroup; 271 | children = ( 272 | D2261FC920416C7B0072956E /* Feature */, 273 | D2261FC520416C7B0072956E /* Library */, 274 | ); 275 | path = Source; 276 | sourceTree = ""; 277 | }; 278 | D2261FBD204163AE0072956E /* Base.lproj */ = { 279 | isa = PBXGroup; 280 | children = ( 281 | D2261FBE204163AE0072956E /* LaunchScreen.storyboard */, 282 | ); 283 | path = Base.lproj; 284 | sourceTree = ""; 285 | }; 286 | D2261FC520416C7B0072956E /* Library */ = { 287 | isa = PBXGroup; 288 | children = ( 289 | D2D5ED4C2043233600BF46BC /* Networking */, 290 | D2D5ED4620431FB900BF46BC /* Utils */, 291 | D2D5ED3B2043140200BF46BC /* Constants */, 292 | D2144ACB20422E8F005CD925 /* View */, 293 | D2144AC22042267A005CD925 /* App */, 294 | D2144AC120421FD9005CD925 /* Base */, 295 | D2144ABC20421C0F005CD925 /* Adapter */, 296 | D2144AB0204216C4005CD925 /* Flow */, 297 | D2261FC620416C7B0072956E /* Extensions */, 298 | D2261FC720416C7B0072956E /* Model */, 299 | D2261FC820416C7B0072956E /* Service */, 300 | ); 301 | path = Library; 302 | sourceTree = ""; 303 | }; 304 | D2261FC620416C7B0072956E /* Extensions */ = { 305 | isa = PBXGroup; 306 | children = ( 307 | D21D3DCF2041D5DC00EF97BC /* UIImageView+Extensions.swift */, 308 | D2144AA1204212DD005CD925 /* UIColor+Extensions.swift */, 309 | D2144AA320421328005CD925 /* NSLayoutConstraint+Extensions.swift */, 310 | D2144AB4204216FC005CD925 /* UIViewController+Extensions.swift */, 311 | D2144AC7204226F6005CD925 /* UICollectionView+Extensions.swift */, 312 | D2144AD22042F218005CD925 /* UIView+Extensions.swift */, 313 | ); 314 | path = Extensions; 315 | sourceTree = ""; 316 | }; 317 | D2261FC720416C7B0072956E /* Model */ = { 318 | isa = PBXGroup; 319 | children = ( 320 | D2261FCA204171690072956E /* Recipe.swift */, 321 | ); 322 | path = Model; 323 | sourceTree = ""; 324 | }; 325 | D2261FC820416C7B0072956E /* Service */ = { 326 | isa = PBXGroup; 327 | children = ( 328 | D21D3DC72041C22F00EF97BC /* NetworkService.swift */, 329 | D21D3DC92041CD4600EF97BC /* RecipesService.swift */, 330 | D21D3DCB2041D59B00EF97BC /* CacheService.swift */, 331 | D21D3DCD2041D5C200EF97BC /* ImageService.swift */, 332 | ); 333 | path = Service; 334 | sourceTree = ""; 335 | }; 336 | D2261FC920416C7B0072956E /* Feature */ = { 337 | isa = PBXGroup; 338 | children = ( 339 | D2144ADB2042FD17005CD925 /* Home */, 340 | D2144AD82042F9E1005CD925 /* Search */, 341 | D2144AAB204214D4005CD925 /* List */, 342 | D2144AA62042138D005CD925 /* Detail */, 343 | ); 344 | path = Feature; 345 | sourceTree = ""; 346 | }; 347 | D29C8B482041622900F6DFC0 = { 348 | isa = PBXGroup; 349 | children = ( 350 | D2261FAC204162D50072956E /* Resource */, 351 | D2261FAE204162D50072956E /* Source */, 352 | D29C8B682041622900F6DFC0 /* RecipesTests */, 353 | D29C8B732041622A00F6DFC0 /* RecipesUITests */, 354 | D29C8B522041622900F6DFC0 /* Products */, 355 | 96E44F5BA420117C22B3E0EE /* Pods */, 356 | 279AB6ABA3DA7E2475179DA4 /* Frameworks */, 357 | ); 358 | sourceTree = ""; 359 | }; 360 | D29C8B522041622900F6DFC0 /* Products */ = { 361 | isa = PBXGroup; 362 | children = ( 363 | D29C8B512041622900F6DFC0 /* Recipes.app */, 364 | D29C8B652041622900F6DFC0 /* RecipesTests.xctest */, 365 | D29C8B702041622A00F6DFC0 /* RecipesUITests.xctest */, 366 | ); 367 | name = Products; 368 | sourceTree = ""; 369 | }; 370 | D29C8B682041622900F6DFC0 /* RecipesTests */ = { 371 | isa = PBXGroup; 372 | children = ( 373 | D2D5ED542043243D00BF46BC /* Fixture */, 374 | D2D5ED532043243D00BF46BC /* Mock */, 375 | D2D1454F204186AB00B31280 /* Library */, 376 | D29C8B6B2041622A00F6DFC0 /* Info.plist */, 377 | ); 378 | path = RecipesTests; 379 | sourceTree = ""; 380 | }; 381 | D29C8B732041622A00F6DFC0 /* RecipesUITests */ = { 382 | isa = PBXGroup; 383 | children = ( 384 | D29C8B742041622A00F6DFC0 /* RecipesUITests.swift */, 385 | D29C8B762041622A00F6DFC0 /* Info.plist */, 386 | ); 387 | path = RecipesUITests; 388 | sourceTree = ""; 389 | }; 390 | D2D1454F204186AB00B31280 /* Library */ = { 391 | isa = PBXGroup; 392 | children = ( 393 | D2D5ED492043201F00BF46BC /* Utils */, 394 | D2D5ED4020431A5A00BF46BC /* Service */, 395 | D2D14550204186AB00B31280 /* Model */, 396 | ); 397 | path = Library; 398 | sourceTree = ""; 399 | }; 400 | D2D14550204186AB00B31280 /* Model */ = { 401 | isa = PBXGroup; 402 | children = ( 403 | D2D14551204186B600B31280 /* RecipeTests.swift */, 404 | ); 405 | path = Model; 406 | sourceTree = ""; 407 | }; 408 | D2D5ED3B2043140200BF46BC /* Constants */ = { 409 | isa = PBXGroup; 410 | children = ( 411 | D2D5ED3C2043140200BF46BC /* R.generated.swift */, 412 | ); 413 | path = Constants; 414 | sourceTree = ""; 415 | }; 416 | D2D5ED4020431A5A00BF46BC /* Service */ = { 417 | isa = PBXGroup; 418 | children = ( 419 | D2D5ED4120431B7B00BF46BC /* CacheServiceTests.swift */, 420 | D2D5ED512043241C00BF46BC /* RecipesService.swift */, 421 | ); 422 | path = Service; 423 | sourceTree = ""; 424 | }; 425 | D2D5ED4620431FB900BF46BC /* Utils */ = { 426 | isa = PBXGroup; 427 | children = ( 428 | D2D5ED4720431FB900BF46BC /* Debouncer.swift */, 429 | ); 430 | path = Utils; 431 | sourceTree = ""; 432 | }; 433 | D2D5ED492043201F00BF46BC /* Utils */ = { 434 | isa = PBXGroup; 435 | children = ( 436 | D2D5ED4A2043201F00BF46BC /* DebouncerTests.swift */, 437 | ); 438 | path = Utils; 439 | sourceTree = ""; 440 | }; 441 | D2D5ED4C2043233600BF46BC /* Networking */ = { 442 | isa = PBXGroup; 443 | children = ( 444 | D2D5ED4D2043234000BF46BC /* Networking.swift */, 445 | D2D5ED4F2043236E00BF46BC /* Resource.swift */, 446 | ); 447 | path = Networking; 448 | sourceTree = ""; 449 | }; 450 | D2D5ED532043243D00BF46BC /* Mock */ = { 451 | isa = PBXGroup; 452 | children = ( 453 | D2D5ED592043246700BF46BC /* MockNetworkService.swift */, 454 | ); 455 | path = Mock; 456 | sourceTree = ""; 457 | }; 458 | D2D5ED542043243D00BF46BC /* Fixture */ = { 459 | isa = PBXGroup; 460 | children = ( 461 | D2D5ED552043244900BF46BC /* recipes.json */, 462 | D2D5ED572043245E00BF46BC /* singleRecipe.json */, 463 | ); 464 | path = Fixture; 465 | sourceTree = ""; 466 | }; 467 | /* End PBXGroup section */ 468 | 469 | /* Begin PBXNativeTarget section */ 470 | D29C8B502041622900F6DFC0 /* Recipes */ = { 471 | isa = PBXNativeTarget; 472 | buildConfigurationList = D29C8B792041622A00F6DFC0 /* Build configuration list for PBXNativeTarget "Recipes" */; 473 | buildPhases = ( 474 | 3ACF734DB59FDA177E1AF598 /* [CP] Check Pods Manifest.lock */, 475 | D2D5ED3A204313D800BF46BC /* R.swift */, 476 | D29C8B4D2041622900F6DFC0 /* Sources */, 477 | D29C8B4E2041622900F6DFC0 /* Frameworks */, 478 | D29C8B4F2041622900F6DFC0 /* Resources */, 479 | D2D1454E204182F600B31280 /* SwiftLint */, 480 | D3627BD33A06E5114D95C50A /* [CP] Embed Pods Frameworks */, 481 | 1AC5414FD7DC6417186C42A8 /* [CP] Copy Pods Resources */, 482 | ); 483 | buildRules = ( 484 | ); 485 | dependencies = ( 486 | ); 487 | name = Recipes; 488 | productName = Recipes; 489 | productReference = D29C8B512041622900F6DFC0 /* Recipes.app */; 490 | productType = "com.apple.product-type.application"; 491 | }; 492 | D29C8B642041622900F6DFC0 /* RecipesTests */ = { 493 | isa = PBXNativeTarget; 494 | buildConfigurationList = D29C8B7C2041622A00F6DFC0 /* Build configuration list for PBXNativeTarget "RecipesTests" */; 495 | buildPhases = ( 496 | 6FF0F054028C8515D88FBDBF /* [CP] Check Pods Manifest.lock */, 497 | D29C8B612041622900F6DFC0 /* Sources */, 498 | D29C8B622041622900F6DFC0 /* Frameworks */, 499 | D29C8B632041622900F6DFC0 /* Resources */, 500 | 6A71297EDC420C872BE9B4AD /* [CP] Embed Pods Frameworks */, 501 | 20108EEAAE22BECAFC47B11B /* [CP] Copy Pods Resources */, 502 | ); 503 | buildRules = ( 504 | ); 505 | dependencies = ( 506 | D29C8B672041622900F6DFC0 /* PBXTargetDependency */, 507 | ); 508 | name = RecipesTests; 509 | productName = RecipesTests; 510 | productReference = D29C8B652041622900F6DFC0 /* RecipesTests.xctest */; 511 | productType = "com.apple.product-type.bundle.unit-test"; 512 | }; 513 | D29C8B6F2041622A00F6DFC0 /* RecipesUITests */ = { 514 | isa = PBXNativeTarget; 515 | buildConfigurationList = D29C8B7F2041622A00F6DFC0 /* Build configuration list for PBXNativeTarget "RecipesUITests" */; 516 | buildPhases = ( 517 | 62602227C717698D72AFCCDF /* [CP] Check Pods Manifest.lock */, 518 | D29C8B6C2041622A00F6DFC0 /* Sources */, 519 | D29C8B6D2041622A00F6DFC0 /* Frameworks */, 520 | D29C8B6E2041622A00F6DFC0 /* Resources */, 521 | 94068D3026872CC2B8F89FC2 /* [CP] Embed Pods Frameworks */, 522 | 15D757B398228501FC0D4448 /* [CP] Copy Pods Resources */, 523 | ); 524 | buildRules = ( 525 | ); 526 | dependencies = ( 527 | D29C8B722041622A00F6DFC0 /* PBXTargetDependency */, 528 | ); 529 | name = RecipesUITests; 530 | productName = RecipesUITests; 531 | productReference = D29C8B702041622A00F6DFC0 /* RecipesUITests.xctest */; 532 | productType = "com.apple.product-type.bundle.ui-testing"; 533 | }; 534 | /* End PBXNativeTarget section */ 535 | 536 | /* Begin PBXProject section */ 537 | D29C8B492041622900F6DFC0 /* Project object */ = { 538 | isa = PBXProject; 539 | attributes = { 540 | LastSwiftUpdateCheck = 0920; 541 | LastUpgradeCheck = 0920; 542 | ORGANIZATIONNAME = "Khoa Pham"; 543 | TargetAttributes = { 544 | D29C8B502041622900F6DFC0 = { 545 | CreatedOnToolsVersion = 9.2; 546 | ProvisioningStyle = Automatic; 547 | }; 548 | D29C8B642041622900F6DFC0 = { 549 | CreatedOnToolsVersion = 9.2; 550 | ProvisioningStyle = Automatic; 551 | TestTargetID = D29C8B502041622900F6DFC0; 552 | }; 553 | D29C8B6F2041622A00F6DFC0 = { 554 | CreatedOnToolsVersion = 9.2; 555 | ProvisioningStyle = Automatic; 556 | TestTargetID = D29C8B502041622900F6DFC0; 557 | }; 558 | }; 559 | }; 560 | buildConfigurationList = D29C8B4C2041622900F6DFC0 /* Build configuration list for PBXProject "Recipes" */; 561 | compatibilityVersion = "Xcode 8.0"; 562 | developmentRegion = en; 563 | hasScannedForEncodings = 0; 564 | knownRegions = ( 565 | en, 566 | Base, 567 | ); 568 | mainGroup = D29C8B482041622900F6DFC0; 569 | productRefGroup = D29C8B522041622900F6DFC0 /* Products */; 570 | projectDirPath = ""; 571 | projectRoot = ""; 572 | targets = ( 573 | D29C8B502041622900F6DFC0 /* Recipes */, 574 | D29C8B642041622900F6DFC0 /* RecipesTests */, 575 | D29C8B6F2041622A00F6DFC0 /* RecipesUITests */, 576 | ); 577 | }; 578 | /* End PBXProject section */ 579 | 580 | /* Begin PBXResourcesBuildPhase section */ 581 | D29C8B4F2041622900F6DFC0 /* Resources */ = { 582 | isa = PBXResourcesBuildPhase; 583 | buildActionMask = 2147483647; 584 | files = ( 585 | D2261FC2204163AE0072956E /* Assets.xcassets in Resources */, 586 | D2261FC3204163AE0072956E /* LaunchScreen.storyboard in Resources */, 587 | ); 588 | runOnlyForDeploymentPostprocessing = 0; 589 | }; 590 | D29C8B632041622900F6DFC0 /* Resources */ = { 591 | isa = PBXResourcesBuildPhase; 592 | buildActionMask = 2147483647; 593 | files = ( 594 | D2D5ED562043244900BF46BC /* recipes.json in Resources */, 595 | D2D5ED582043245E00BF46BC /* singleRecipe.json in Resources */, 596 | ); 597 | runOnlyForDeploymentPostprocessing = 0; 598 | }; 599 | D29C8B6E2041622A00F6DFC0 /* Resources */ = { 600 | isa = PBXResourcesBuildPhase; 601 | buildActionMask = 2147483647; 602 | files = ( 603 | ); 604 | runOnlyForDeploymentPostprocessing = 0; 605 | }; 606 | /* End PBXResourcesBuildPhase section */ 607 | 608 | /* Begin PBXShellScriptBuildPhase section */ 609 | 15D757B398228501FC0D4448 /* [CP] Copy Pods Resources */ = { 610 | isa = PBXShellScriptBuildPhase; 611 | buildActionMask = 2147483647; 612 | files = ( 613 | ); 614 | inputPaths = ( 615 | ); 616 | name = "[CP] Copy Pods Resources"; 617 | outputPaths = ( 618 | ); 619 | runOnlyForDeploymentPostprocessing = 0; 620 | shellPath = /bin/sh; 621 | shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-RecipesUITests/Pods-RecipesUITests-resources.sh\"\n"; 622 | showEnvVarsInLog = 0; 623 | }; 624 | 1AC5414FD7DC6417186C42A8 /* [CP] Copy Pods Resources */ = { 625 | isa = PBXShellScriptBuildPhase; 626 | buildActionMask = 2147483647; 627 | files = ( 628 | ); 629 | inputPaths = ( 630 | ); 631 | name = "[CP] Copy Pods Resources"; 632 | outputPaths = ( 633 | ); 634 | runOnlyForDeploymentPostprocessing = 0; 635 | shellPath = /bin/sh; 636 | shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-Recipes/Pods-Recipes-resources.sh\"\n"; 637 | showEnvVarsInLog = 0; 638 | }; 639 | 20108EEAAE22BECAFC47B11B /* [CP] Copy Pods Resources */ = { 640 | isa = PBXShellScriptBuildPhase; 641 | buildActionMask = 2147483647; 642 | files = ( 643 | ); 644 | inputPaths = ( 645 | ); 646 | name = "[CP] Copy Pods Resources"; 647 | outputPaths = ( 648 | ); 649 | runOnlyForDeploymentPostprocessing = 0; 650 | shellPath = /bin/sh; 651 | shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-RecipesTests/Pods-RecipesTests-resources.sh\"\n"; 652 | showEnvVarsInLog = 0; 653 | }; 654 | 3ACF734DB59FDA177E1AF598 /* [CP] Check Pods Manifest.lock */ = { 655 | isa = PBXShellScriptBuildPhase; 656 | buildActionMask = 2147483647; 657 | files = ( 658 | ); 659 | inputPaths = ( 660 | "${PODS_PODFILE_DIR_PATH}/Podfile.lock", 661 | "${PODS_ROOT}/Manifest.lock", 662 | ); 663 | name = "[CP] Check Pods Manifest.lock"; 664 | outputPaths = ( 665 | "$(DERIVED_FILE_DIR)/Pods-Recipes-checkManifestLockResult.txt", 666 | ); 667 | runOnlyForDeploymentPostprocessing = 0; 668 | shellPath = /bin/sh; 669 | shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; 670 | showEnvVarsInLog = 0; 671 | }; 672 | 62602227C717698D72AFCCDF /* [CP] Check Pods Manifest.lock */ = { 673 | isa = PBXShellScriptBuildPhase; 674 | buildActionMask = 2147483647; 675 | files = ( 676 | ); 677 | inputPaths = ( 678 | "${PODS_PODFILE_DIR_PATH}/Podfile.lock", 679 | "${PODS_ROOT}/Manifest.lock", 680 | ); 681 | name = "[CP] Check Pods Manifest.lock"; 682 | outputPaths = ( 683 | "$(DERIVED_FILE_DIR)/Pods-RecipesUITests-checkManifestLockResult.txt", 684 | ); 685 | runOnlyForDeploymentPostprocessing = 0; 686 | shellPath = /bin/sh; 687 | shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; 688 | showEnvVarsInLog = 0; 689 | }; 690 | 6A71297EDC420C872BE9B4AD /* [CP] Embed Pods Frameworks */ = { 691 | isa = PBXShellScriptBuildPhase; 692 | buildActionMask = 2147483647; 693 | files = ( 694 | ); 695 | inputPaths = ( 696 | ); 697 | name = "[CP] Embed Pods Frameworks"; 698 | outputPaths = ( 699 | ); 700 | runOnlyForDeploymentPostprocessing = 0; 701 | shellPath = /bin/sh; 702 | shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-RecipesTests/Pods-RecipesTests-frameworks.sh\"\n"; 703 | showEnvVarsInLog = 0; 704 | }; 705 | 6FF0F054028C8515D88FBDBF /* [CP] Check Pods Manifest.lock */ = { 706 | isa = PBXShellScriptBuildPhase; 707 | buildActionMask = 2147483647; 708 | files = ( 709 | ); 710 | inputPaths = ( 711 | "${PODS_PODFILE_DIR_PATH}/Podfile.lock", 712 | "${PODS_ROOT}/Manifest.lock", 713 | ); 714 | name = "[CP] Check Pods Manifest.lock"; 715 | outputPaths = ( 716 | "$(DERIVED_FILE_DIR)/Pods-RecipesTests-checkManifestLockResult.txt", 717 | ); 718 | runOnlyForDeploymentPostprocessing = 0; 719 | shellPath = /bin/sh; 720 | shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; 721 | showEnvVarsInLog = 0; 722 | }; 723 | 94068D3026872CC2B8F89FC2 /* [CP] Embed Pods Frameworks */ = { 724 | isa = PBXShellScriptBuildPhase; 725 | buildActionMask = 2147483647; 726 | files = ( 727 | ); 728 | inputPaths = ( 729 | ); 730 | name = "[CP] Embed Pods Frameworks"; 731 | outputPaths = ( 732 | ); 733 | runOnlyForDeploymentPostprocessing = 0; 734 | shellPath = /bin/sh; 735 | shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-RecipesUITests/Pods-RecipesUITests-frameworks.sh\"\n"; 736 | showEnvVarsInLog = 0; 737 | }; 738 | D2D1454E204182F600B31280 /* SwiftLint */ = { 739 | isa = PBXShellScriptBuildPhase; 740 | buildActionMask = 2147483647; 741 | files = ( 742 | ); 743 | inputPaths = ( 744 | ); 745 | name = SwiftLint; 746 | outputPaths = ( 747 | ); 748 | runOnlyForDeploymentPostprocessing = 0; 749 | shellPath = /bin/sh; 750 | shellScript = "if which swiftlint >/dev/null; then\nswiftlint\nelse\necho \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi"; 751 | }; 752 | D2D5ED3A204313D800BF46BC /* R.swift */ = { 753 | isa = PBXShellScriptBuildPhase; 754 | buildActionMask = 2147483647; 755 | files = ( 756 | ); 757 | inputPaths = ( 758 | ); 759 | name = R.swift; 760 | outputPaths = ( 761 | ); 762 | runOnlyForDeploymentPostprocessing = 0; 763 | shellPath = /bin/sh; 764 | shellScript = "\"$PODS_ROOT/R.swift/rswift\" generate \"$SRCROOT/Source/Library/Constants\""; 765 | }; 766 | D3627BD33A06E5114D95C50A /* [CP] Embed Pods Frameworks */ = { 767 | isa = PBXShellScriptBuildPhase; 768 | buildActionMask = 2147483647; 769 | files = ( 770 | ); 771 | inputPaths = ( 772 | "${SRCROOT}/Pods/Target Support Files/Pods-Recipes/Pods-Recipes-frameworks.sh", 773 | "${BUILT_PRODUCTS_DIR}/R.swift.Library/Rswift.framework", 774 | "${BUILT_PRODUCTS_DIR}/SwiftHash/SwiftHash.framework", 775 | ); 776 | name = "[CP] Embed Pods Frameworks"; 777 | outputPaths = ( 778 | "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Rswift.framework", 779 | "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SwiftHash.framework", 780 | ); 781 | runOnlyForDeploymentPostprocessing = 0; 782 | shellPath = /bin/sh; 783 | shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-Recipes/Pods-Recipes-frameworks.sh\"\n"; 784 | showEnvVarsInLog = 0; 785 | }; 786 | /* End PBXShellScriptBuildPhase section */ 787 | 788 | /* Begin PBXSourcesBuildPhase section */ 789 | D29C8B4D2041622900F6DFC0 /* Sources */ = { 790 | isa = PBXSourcesBuildPhase; 791 | buildActionMask = 2147483647; 792 | files = ( 793 | D2144ADD2042FD1E005CD925 /* HomeViewController.swift in Sources */, 794 | D21D3DCA2041CD4600EF97BC /* RecipesService.swift in Sources */, 795 | D2144ADA2042FA12005CD925 /* SearchComponent.swift in Sources */, 796 | D2D5ED502043236E00BF46BC /* Resource.swift in Sources */, 797 | D2144AB5204216FC005CD925 /* UIViewController+Extensions.swift in Sources */, 798 | D2144AC020421E4C005CD925 /* RecipeCell.swift in Sources */, 799 | D2144AB72042173F005CD925 /* RecipeFlowController.swift in Sources */, 800 | D2261FCB204171690072956E /* Recipe.swift in Sources */, 801 | D2144ACD20422EA5005CD925 /* ScrollableView.swift in Sources */, 802 | D2144AB3204216CB005CD925 /* AppFlowController.swift in Sources */, 803 | D2D5ED4820431FB900BF46BC /* Debouncer.swift in Sources */, 804 | D2144AD120422ED5005CD925 /* RecipeDetailView.swift in Sources */, 805 | D2144AAD204214D4005CD925 /* RecipeListViewController.swift in Sources */, 806 | D2144AA420421328005CD925 /* NSLayoutConstraint+Extensions.swift in Sources */, 807 | D2D5ED3F2043178800BF46BC /* EmptyView.swift in Sources */, 808 | D2144AC42042267A005CD925 /* AppDelegate.swift in Sources */, 809 | D21D3DC82041C22F00EF97BC /* NetworkService.swift in Sources */, 810 | D2144AC620422686005CD925 /* AppConfig.swift in Sources */, 811 | D2144AD32042F218005CD925 /* UIView+Extensions.swift in Sources */, 812 | D2144ACA20422E19005CD925 /* Color.swift in Sources */, 813 | D21D3DCC2041D59B00EF97BC /* CacheService.swift in Sources */, 814 | D2144AAA2042146D005CD925 /* RecipeDetailViewController.swift in Sources */, 815 | D2D5ED3D2043140200BF46BC /* R.generated.swift in Sources */, 816 | D21D3DD02041D5DC00EF97BC /* UIImageView+Extensions.swift in Sources */, 817 | D2144ACF20422EAE005CD925 /* BaseController.swift in Sources */, 818 | D2144AC8204226F6005CD925 /* UICollectionView+Extensions.swift in Sources */, 819 | D2144AA2204212DD005CD925 /* UIColor+Extensions.swift in Sources */, 820 | D2D5ED4E2043234000BF46BC /* Networking.swift in Sources */, 821 | D2144ABE20421C16005CD925 /* Adapter.swift in Sources */, 822 | D21D3DCE2041D5C200EF97BC /* ImageService.swift in Sources */, 823 | ); 824 | runOnlyForDeploymentPostprocessing = 0; 825 | }; 826 | D29C8B612041622900F6DFC0 /* Sources */ = { 827 | isa = PBXSourcesBuildPhase; 828 | buildActionMask = 2147483647; 829 | files = ( 830 | D2D5ED4220431B7B00BF46BC /* CacheServiceTests.swift in Sources */, 831 | D2D14552204186B600B31280 /* RecipeTests.swift in Sources */, 832 | D2D5ED5A2043246700BF46BC /* MockNetworkService.swift in Sources */, 833 | D2D5ED4B2043201F00BF46BC /* DebouncerTests.swift in Sources */, 834 | D2D5ED522043241C00BF46BC /* RecipesService.swift in Sources */, 835 | ); 836 | runOnlyForDeploymentPostprocessing = 0; 837 | }; 838 | D29C8B6C2041622A00F6DFC0 /* Sources */ = { 839 | isa = PBXSourcesBuildPhase; 840 | buildActionMask = 2147483647; 841 | files = ( 842 | D29C8B752041622A00F6DFC0 /* RecipesUITests.swift in Sources */, 843 | ); 844 | runOnlyForDeploymentPostprocessing = 0; 845 | }; 846 | /* End PBXSourcesBuildPhase section */ 847 | 848 | /* Begin PBXTargetDependency section */ 849 | D29C8B672041622900F6DFC0 /* PBXTargetDependency */ = { 850 | isa = PBXTargetDependency; 851 | target = D29C8B502041622900F6DFC0 /* Recipes */; 852 | targetProxy = D29C8B662041622900F6DFC0 /* PBXContainerItemProxy */; 853 | }; 854 | D29C8B722041622A00F6DFC0 /* PBXTargetDependency */ = { 855 | isa = PBXTargetDependency; 856 | target = D29C8B502041622900F6DFC0 /* Recipes */; 857 | targetProxy = D29C8B712041622A00F6DFC0 /* PBXContainerItemProxy */; 858 | }; 859 | /* End PBXTargetDependency section */ 860 | 861 | /* Begin PBXVariantGroup section */ 862 | D2261FBE204163AE0072956E /* LaunchScreen.storyboard */ = { 863 | isa = PBXVariantGroup; 864 | children = ( 865 | D2261FBF204163AE0072956E /* Base */, 866 | ); 867 | name = LaunchScreen.storyboard; 868 | sourceTree = ""; 869 | }; 870 | /* End PBXVariantGroup section */ 871 | 872 | /* Begin XCBuildConfiguration section */ 873 | D29C8B772041622A00F6DFC0 /* Debug */ = { 874 | isa = XCBuildConfiguration; 875 | buildSettings = { 876 | ALWAYS_SEARCH_USER_PATHS = NO; 877 | CLANG_ANALYZER_NONNULL = YES; 878 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 879 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 880 | CLANG_CXX_LIBRARY = "libc++"; 881 | CLANG_ENABLE_MODULES = YES; 882 | CLANG_ENABLE_OBJC_ARC = YES; 883 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 884 | CLANG_WARN_BOOL_CONVERSION = YES; 885 | CLANG_WARN_COMMA = YES; 886 | CLANG_WARN_CONSTANT_CONVERSION = YES; 887 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 888 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 889 | CLANG_WARN_EMPTY_BODY = YES; 890 | CLANG_WARN_ENUM_CONVERSION = YES; 891 | CLANG_WARN_INFINITE_RECURSION = YES; 892 | CLANG_WARN_INT_CONVERSION = YES; 893 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 894 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 895 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 896 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 897 | CLANG_WARN_STRICT_PROTOTYPES = YES; 898 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 899 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 900 | CLANG_WARN_UNREACHABLE_CODE = YES; 901 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 902 | CODE_SIGN_IDENTITY = "iPhone Developer"; 903 | COPY_PHASE_STRIP = NO; 904 | DEBUG_INFORMATION_FORMAT = dwarf; 905 | ENABLE_STRICT_OBJC_MSGSEND = YES; 906 | ENABLE_TESTABILITY = YES; 907 | GCC_C_LANGUAGE_STANDARD = gnu11; 908 | GCC_DYNAMIC_NO_PIC = NO; 909 | GCC_NO_COMMON_BLOCKS = YES; 910 | GCC_OPTIMIZATION_LEVEL = 0; 911 | GCC_PREPROCESSOR_DEFINITIONS = ( 912 | "DEBUG=1", 913 | "$(inherited)", 914 | ); 915 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 916 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 917 | GCC_WARN_UNDECLARED_SELECTOR = YES; 918 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 919 | GCC_WARN_UNUSED_FUNCTION = YES; 920 | GCC_WARN_UNUSED_VARIABLE = YES; 921 | IPHONEOS_DEPLOYMENT_TARGET = 10.0; 922 | MTL_ENABLE_DEBUG_INFO = YES; 923 | ONLY_ACTIVE_ARCH = YES; 924 | SDKROOT = iphoneos; 925 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 926 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 927 | SWIFT_VERSION = 4.0; 928 | }; 929 | name = Debug; 930 | }; 931 | D29C8B782041622A00F6DFC0 /* Release */ = { 932 | isa = XCBuildConfiguration; 933 | buildSettings = { 934 | ALWAYS_SEARCH_USER_PATHS = NO; 935 | CLANG_ANALYZER_NONNULL = YES; 936 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 937 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 938 | CLANG_CXX_LIBRARY = "libc++"; 939 | CLANG_ENABLE_MODULES = YES; 940 | CLANG_ENABLE_OBJC_ARC = YES; 941 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 942 | CLANG_WARN_BOOL_CONVERSION = YES; 943 | CLANG_WARN_COMMA = YES; 944 | CLANG_WARN_CONSTANT_CONVERSION = YES; 945 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 946 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 947 | CLANG_WARN_EMPTY_BODY = YES; 948 | CLANG_WARN_ENUM_CONVERSION = YES; 949 | CLANG_WARN_INFINITE_RECURSION = YES; 950 | CLANG_WARN_INT_CONVERSION = YES; 951 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 952 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 953 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 954 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 955 | CLANG_WARN_STRICT_PROTOTYPES = YES; 956 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 957 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 958 | CLANG_WARN_UNREACHABLE_CODE = YES; 959 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 960 | CODE_SIGN_IDENTITY = "iPhone Developer"; 961 | COPY_PHASE_STRIP = NO; 962 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 963 | ENABLE_NS_ASSERTIONS = NO; 964 | ENABLE_STRICT_OBJC_MSGSEND = YES; 965 | GCC_C_LANGUAGE_STANDARD = gnu11; 966 | GCC_NO_COMMON_BLOCKS = YES; 967 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 968 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 969 | GCC_WARN_UNDECLARED_SELECTOR = YES; 970 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 971 | GCC_WARN_UNUSED_FUNCTION = YES; 972 | GCC_WARN_UNUSED_VARIABLE = YES; 973 | IPHONEOS_DEPLOYMENT_TARGET = 10.0; 974 | MTL_ENABLE_DEBUG_INFO = NO; 975 | SDKROOT = iphoneos; 976 | SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; 977 | SWIFT_VERSION = 4.0; 978 | VALIDATE_PRODUCT = YES; 979 | }; 980 | name = Release; 981 | }; 982 | D29C8B7A2041622A00F6DFC0 /* Debug */ = { 983 | isa = XCBuildConfiguration; 984 | baseConfigurationReference = 67AB69A190C809D9EB64DDAE /* Pods-Recipes.debug.xcconfig */; 985 | buildSettings = { 986 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 987 | CODE_SIGN_STYLE = Automatic; 988 | DEVELOPMENT_TEAM = T78DK947F2; 989 | INFOPLIST_FILE = "$(SRCROOT)/Resource/Info.plist"; 990 | IPHONEOS_DEPLOYMENT_TARGET = 10.0; 991 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; 992 | PRODUCT_BUNDLE_IDENTIFIER = no.onmyway133.Recipes; 993 | PRODUCT_NAME = "$(TARGET_NAME)"; 994 | TARGETED_DEVICE_FAMILY = "1,2"; 995 | }; 996 | name = Debug; 997 | }; 998 | D29C8B7B2041622A00F6DFC0 /* Release */ = { 999 | isa = XCBuildConfiguration; 1000 | baseConfigurationReference = 0B7FB436DB3ED6792BB13CF2 /* Pods-Recipes.release.xcconfig */; 1001 | buildSettings = { 1002 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 1003 | CODE_SIGN_STYLE = Automatic; 1004 | DEVELOPMENT_TEAM = T78DK947F2; 1005 | INFOPLIST_FILE = "$(SRCROOT)/Resource/Info.plist"; 1006 | IPHONEOS_DEPLOYMENT_TARGET = 10.0; 1007 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; 1008 | PRODUCT_BUNDLE_IDENTIFIER = no.onmyway133.Recipes; 1009 | PRODUCT_NAME = "$(TARGET_NAME)"; 1010 | TARGETED_DEVICE_FAMILY = "1,2"; 1011 | }; 1012 | name = Release; 1013 | }; 1014 | D29C8B7D2041622A00F6DFC0 /* Debug */ = { 1015 | isa = XCBuildConfiguration; 1016 | baseConfigurationReference = 55CB896310501C0C274522CF /* Pods-RecipesTests.debug.xcconfig */; 1017 | buildSettings = { 1018 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 1019 | BUNDLE_LOADER = "$(TEST_HOST)"; 1020 | CODE_SIGN_STYLE = Automatic; 1021 | DEVELOPMENT_TEAM = T78DK947F2; 1022 | INFOPLIST_FILE = RecipesTests/Info.plist; 1023 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; 1024 | PRODUCT_BUNDLE_IDENTIFIER = no.onmyway133.RecipesTests; 1025 | PRODUCT_NAME = "$(TARGET_NAME)"; 1026 | SWIFT_VERSION = 4.0; 1027 | TARGETED_DEVICE_FAMILY = "1,2"; 1028 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Recipes.app/Recipes"; 1029 | }; 1030 | name = Debug; 1031 | }; 1032 | D29C8B7E2041622A00F6DFC0 /* Release */ = { 1033 | isa = XCBuildConfiguration; 1034 | baseConfigurationReference = A63E1C4979821F120A7D2DFA /* Pods-RecipesTests.release.xcconfig */; 1035 | buildSettings = { 1036 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 1037 | BUNDLE_LOADER = "$(TEST_HOST)"; 1038 | CODE_SIGN_STYLE = Automatic; 1039 | DEVELOPMENT_TEAM = T78DK947F2; 1040 | INFOPLIST_FILE = RecipesTests/Info.plist; 1041 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; 1042 | PRODUCT_BUNDLE_IDENTIFIER = no.onmyway133.RecipesTests; 1043 | PRODUCT_NAME = "$(TARGET_NAME)"; 1044 | SWIFT_VERSION = 4.0; 1045 | TARGETED_DEVICE_FAMILY = "1,2"; 1046 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Recipes.app/Recipes"; 1047 | }; 1048 | name = Release; 1049 | }; 1050 | D29C8B802041622A00F6DFC0 /* Debug */ = { 1051 | isa = XCBuildConfiguration; 1052 | baseConfigurationReference = 3E29D2FB9B154AD6EA94130B /* Pods-RecipesUITests.debug.xcconfig */; 1053 | buildSettings = { 1054 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 1055 | CODE_SIGN_STYLE = Automatic; 1056 | DEVELOPMENT_TEAM = T78DK947F2; 1057 | INFOPLIST_FILE = RecipesUITests/Info.plist; 1058 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; 1059 | PRODUCT_BUNDLE_IDENTIFIER = no.onmyway133.RecipesUITests; 1060 | PRODUCT_NAME = "$(TARGET_NAME)"; 1061 | SWIFT_VERSION = 4.0; 1062 | TARGETED_DEVICE_FAMILY = "1,2"; 1063 | TEST_TARGET_NAME = Recipes; 1064 | }; 1065 | name = Debug; 1066 | }; 1067 | D29C8B812041622A00F6DFC0 /* Release */ = { 1068 | isa = XCBuildConfiguration; 1069 | baseConfigurationReference = 79900A81992AB62795E12ACA /* Pods-RecipesUITests.release.xcconfig */; 1070 | buildSettings = { 1071 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 1072 | CODE_SIGN_STYLE = Automatic; 1073 | DEVELOPMENT_TEAM = T78DK947F2; 1074 | INFOPLIST_FILE = RecipesUITests/Info.plist; 1075 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; 1076 | PRODUCT_BUNDLE_IDENTIFIER = no.onmyway133.RecipesUITests; 1077 | PRODUCT_NAME = "$(TARGET_NAME)"; 1078 | SWIFT_VERSION = 4.0; 1079 | TARGETED_DEVICE_FAMILY = "1,2"; 1080 | TEST_TARGET_NAME = Recipes; 1081 | }; 1082 | name = Release; 1083 | }; 1084 | /* End XCBuildConfiguration section */ 1085 | 1086 | /* Begin XCConfigurationList section */ 1087 | D29C8B4C2041622900F6DFC0 /* Build configuration list for PBXProject "Recipes" */ = { 1088 | isa = XCConfigurationList; 1089 | buildConfigurations = ( 1090 | D29C8B772041622A00F6DFC0 /* Debug */, 1091 | D29C8B782041622A00F6DFC0 /* Release */, 1092 | ); 1093 | defaultConfigurationIsVisible = 0; 1094 | defaultConfigurationName = Release; 1095 | }; 1096 | D29C8B792041622A00F6DFC0 /* Build configuration list for PBXNativeTarget "Recipes" */ = { 1097 | isa = XCConfigurationList; 1098 | buildConfigurations = ( 1099 | D29C8B7A2041622A00F6DFC0 /* Debug */, 1100 | D29C8B7B2041622A00F6DFC0 /* Release */, 1101 | ); 1102 | defaultConfigurationIsVisible = 0; 1103 | defaultConfigurationName = Release; 1104 | }; 1105 | D29C8B7C2041622A00F6DFC0 /* Build configuration list for PBXNativeTarget "RecipesTests" */ = { 1106 | isa = XCConfigurationList; 1107 | buildConfigurations = ( 1108 | D29C8B7D2041622A00F6DFC0 /* Debug */, 1109 | D29C8B7E2041622A00F6DFC0 /* Release */, 1110 | ); 1111 | defaultConfigurationIsVisible = 0; 1112 | defaultConfigurationName = Release; 1113 | }; 1114 | D29C8B7F2041622A00F6DFC0 /* Build configuration list for PBXNativeTarget "RecipesUITests" */ = { 1115 | isa = XCConfigurationList; 1116 | buildConfigurations = ( 1117 | D29C8B802041622A00F6DFC0 /* Debug */, 1118 | D29C8B812041622A00F6DFC0 /* Release */, 1119 | ); 1120 | defaultConfigurationIsVisible = 0; 1121 | defaultConfigurationName = Release; 1122 | }; 1123 | /* End XCConfigurationList section */ 1124 | }; 1125 | rootObject = D29C8B492041622900F6DFC0 /* Project object */; 1126 | } 1127 | -------------------------------------------------------------------------------- /RecipesTests/Fixture/recipes.json: -------------------------------------------------------------------------------- 1 | { 2 | "count": 30, 3 | "recipes": [ 4 | { 5 | "publisher": "The Pioneer Woman", 6 | "f2f_url": "http://food2fork.com/view/47024", 7 | "title": "Perfect Iced Coffee", 8 | "source_url": "http://thepioneerwoman.com/cooking/2011/06/perfect-iced-coffee/", 9 | "recipe_id": "47024", 10 | "image_url": "http://static.food2fork.com/icedcoffee5766.jpg", 11 | "social_rank": 100.0, 12 | "publisher_url": "http://thepioneerwoman.com" 13 | }, 14 | { 15 | "publisher": "Closet Cooking", 16 | "f2f_url": "http://food2fork.com/view/35382", 17 | "title": "Jalapeno Popper Grilled Cheese Sandwich", 18 | "source_url": "http://www.closetcooking.com/2011/04/jalapeno-popper-grilled-cheese-sandwich.html", 19 | "recipe_id": "35382", 20 | "image_url": "http://static.food2fork.com/Jalapeno2BPopper2BGrilled2BCheese2BSandwich2B12B500fd186186.jpg", 21 | "social_rank": 100.0, 22 | "publisher_url": "http://closetcooking.com" 23 | }, 24 | { 25 | "publisher": "The Pioneer Woman", 26 | "f2f_url": "http://food2fork.com/view/47319", 27 | "title": "Crash Hot Potatoes", 28 | "source_url": "http://thepioneerwoman.com/cooking/2008/06/crash-hot-potatoes/", 29 | "recipe_id": "47319", 30 | "image_url": "http://static.food2fork.com/CrashHotPotatoes5736.jpg", 31 | "social_rank": 100.0, 32 | "publisher_url": "http://thepioneerwoman.com" 33 | }, 34 | { 35 | "publisher": "Two Peas and Their Pod", 36 | "f2f_url": "http://food2fork.com/view/54384", 37 | "title": "Stovetop Avocado Mac and Cheese", 38 | "source_url": "http://www.twopeasandtheirpod.com/stovetop-avocado-mac-and-cheese/", 39 | "recipe_id": "54384", 40 | "image_url": "http://static.food2fork.com/avocadomacandcheesedc99.jpg", 41 | "social_rank": 100.0, 42 | "publisher_url": "http://www.twopeasandtheirpod.com" 43 | }, 44 | { 45 | "publisher": "Closet Cooking", 46 | "f2f_url": "http://food2fork.com/view/35171", 47 | "title": "Buffalo Chicken Grilled Cheese Sandwich", 48 | "source_url": "http://www.closetcooking.com/2011/08/buffalo-chicken-grilled-cheese-sandwich.html", 49 | "recipe_id": "35171", 50 | "image_url": "http://static.food2fork.com/Buffalo2BChicken2BGrilled2BCheese2BSandwich2B5002B4983f2702fe4.jpg", 51 | "social_rank": 100.0, 52 | "publisher_url": "http://closetcooking.com" 53 | }, 54 | { 55 | "publisher": "The Pioneer Woman", 56 | "f2f_url": "http://food2fork.com/view/d9a5e8", 57 | "title": "Cinnamon Rolls", 58 | "source_url": "http://thepioneerwoman.com/cooking/2007/06/cinammon_rolls_/", 59 | "recipe_id": "d9a5e8", 60 | "image_url": "http://static.food2fork.com/333323997_04bd8d6c53da11.jpg", 61 | "social_rank": 100.0, 62 | "publisher_url": "http://thepioneerwoman.com" 63 | }, 64 | { 65 | "publisher": "101 Cookbooks", 66 | "f2f_url": "http://food2fork.com/view/47746", 67 | "title": "Best Pizza Dough Ever", 68 | "source_url": "http://www.101cookbooks.com/archives/001199.html", 69 | "recipe_id": "47746", 70 | "image_url": "http://static.food2fork.com/best_pizza_dough_recipe1b20.jpg", 71 | "social_rank": 100.0, 72 | "publisher_url": "http://www.101cookbooks.com" 73 | }, 74 | { 75 | "publisher": "101 Cookbooks", 76 | "f2f_url": "http://food2fork.com/view/47899", 77 | "title": "Magic Sauce", 78 | "source_url": "http://www.101cookbooks.com/archives/magic-sauce-recipe.html", 79 | "recipe_id": "47899", 80 | "image_url": "http://static.food2fork.com/magic_sauce_recipeece9.jpg", 81 | "social_rank": 100.0, 82 | "publisher_url": "http://www.101cookbooks.com" 83 | }, 84 | { 85 | "publisher": "The Pioneer Woman", 86 | "f2f_url": "http://food2fork.com/view/47042", 87 | "title": "Spicy Dr. Pepper Shredded Pork", 88 | "source_url": "http://thepioneerwoman.com/cooking/2011/03/spicy-dr-pepper-shredded-pork/", 89 | "recipe_id": "47042", 90 | "image_url": "http://static.food2fork.com/5551711173_dc42f7fc4b_zbd8a.jpg", 91 | "social_rank": 100.0, 92 | "publisher_url": "http://thepioneerwoman.com" 93 | }, 94 | { 95 | "publisher": "Whats Gaby Cooking", 96 | "f2f_url": "http://food2fork.com/view/713134", 97 | "title": "Parmesan Roasted Potatoes", 98 | "source_url": "http://whatsgabycooking.com/parmesan-roasted-potatoes/", 99 | "recipe_id": "713134", 100 | "image_url": "http://static.food2fork.com/ParmesanRoastedPotatoes11985a.jpg", 101 | "social_rank": 100.0, 102 | "publisher_url": "http://whatsgabycooking.com" 103 | }, 104 | { 105 | "publisher": "Closet Cooking", 106 | "f2f_url": "http://food2fork.com/view/35120", 107 | "title": "Bacon Wrapped Jalapeno Popper Stuffed Chicken", 108 | "source_url": "http://www.closetcooking.com/2012/11/bacon-wrapped-jalapeno-popper-stuffed.html", 109 | "recipe_id": "35120", 110 | "image_url": "http://static.food2fork.com/Bacon2BWrapped2BJalapeno2BPopper2BStuffed2BChicken2B5002B5909939b0e65.jpg", 111 | "social_rank": 100.0, 112 | "publisher_url": "http://closetcooking.com" 113 | }, 114 | { 115 | "publisher": "My Baking Addiction", 116 | "f2f_url": "http://food2fork.com/view/035865", 117 | "title": "The Best Chocolate Cake", 118 | "source_url": "http://www.mybakingaddiction.com/the-best-chocolate-cake-recipe/", 119 | "recipe_id": "035865", 120 | "image_url": "http://static.food2fork.com/BlackMagicCakeSlice1of18c68.jpg", 121 | "social_rank": 100.0, 122 | "publisher_url": "http://www.mybakingaddiction.com" 123 | }, 124 | { 125 | "publisher": "Closet Cooking", 126 | "f2f_url": "http://food2fork.com/view/35368", 127 | "title": "Hot Spinach and Artichoke Dip", 128 | "source_url": "http://www.closetcooking.com/2008/11/hot-spinach-and-artichoke-dip.html", 129 | "recipe_id": "35368", 130 | "image_url": "http://static.food2fork.com/HotSpinachandArtichokeDip5007579cdf5.jpg", 131 | "social_rank": 100.0, 132 | "publisher_url": "http://closetcooking.com" 133 | }, 134 | { 135 | "publisher": "Two Peas and Their Pod", 136 | "f2f_url": "http://food2fork.com/view/54400", 137 | "title": "Smashed Chickpea & Avocado Salad Sandwich", 138 | "source_url": "http://www.twopeasandtheirpod.com/smashed-chickpea-avocado-salad-sandwich/", 139 | "recipe_id": "54400", 140 | "image_url": "http://static.food2fork.com/smashedchickpeaavocadosaladsandwich29c5b.jpg", 141 | "social_rank": 100.0, 142 | "publisher_url": "http://www.twopeasandtheirpod.com" 143 | }, 144 | { 145 | "publisher": "The Pioneer Woman", 146 | "f2f_url": "http://food2fork.com/view/47166", 147 | "title": "Restaurant Style Salsa", 148 | "source_url": "http://thepioneerwoman.com/cooking/2010/01/restaurant-style-salsa/", 149 | "recipe_id": "47166", 150 | "image_url": "http://static.food2fork.com/4307514771_c089bbd71017f42.jpg", 151 | "social_rank": 100.0, 152 | "publisher_url": "http://thepioneerwoman.com" 153 | }, 154 | { 155 | "publisher": "The Pioneer Woman", 156 | "f2f_url": "http://food2fork.com/view/8f3e73", 157 | "title": "The Best Lasagna Ever", 158 | "source_url": "http://thepioneerwoman.com/cooking/2007/06/the_best_lasagn/", 159 | "recipe_id": "8f3e73", 160 | "image_url": "http://static.food2fork.com/387114468_aafd1be3404a2f.jpg", 161 | "social_rank": 100.0, 162 | "publisher_url": "http://thepioneerwoman.com" 163 | }, 164 | { 165 | "publisher": "My Baking Addiction", 166 | "f2f_url": "http://food2fork.com/view/e7fdb2", 167 | "title": "Mac and Cheese with Roasted Chicken, Goat Cheese, and Rosemary", 168 | "source_url": "http://www.mybakingaddiction.com/mac-and-cheese-roasted-chicken-and-goat-cheese/", 169 | "recipe_id": "e7fdb2", 170 | "image_url": "http://static.food2fork.com/MacandCheese1122b.jpg", 171 | "social_rank": 100.0, 172 | "publisher_url": "http://www.mybakingaddiction.com" 173 | }, 174 | { 175 | "publisher": "Closet Cooking", 176 | "f2f_url": "http://food2fork.com/view/35354", 177 | "title": "Guinness Chocolate Cheesecake", 178 | "source_url": "http://www.closetcooking.com/2011/03/guinness-chocolate-cheesecake.html", 179 | "recipe_id": "35354", 180 | "image_url": "http://static.food2fork.com/Guinness2BChocolate2BCheesecake2B12B5002af4b6b4.jpg", 181 | "social_rank": 100.0, 182 | "publisher_url": "http://closetcooking.com" 183 | }, 184 | { 185 | "publisher": "Simply Recipes", 186 | "f2f_url": "http://food2fork.com/view/35760", 187 | "title": "Banana Bread", 188 | "source_url": "http://www.simplyrecipes.com/recipes/banana_bread/", 189 | "recipe_id": "35760", 190 | "image_url": "http://static.food2fork.com/banana_bread300x2000a14c8c5.jpeg", 191 | "social_rank": 100.0, 192 | "publisher_url": "http://simplyrecipes.com" 193 | }, 194 | { 195 | "publisher": "All Recipes", 196 | "f2f_url": "http://food2fork.com/view/32478", 197 | "title": "The Best Rolled Sugar Cookies", 198 | "source_url": "http://allrecipes.com/Recipe/The-Best-Rolled-Sugar-Cookies/Detail.aspx", 199 | "recipe_id": "32478", 200 | "image_url": "http://static.food2fork.com/9956913c10.jpg", 201 | "social_rank": 100.0, 202 | "publisher_url": "http://allrecipes.com" 203 | }, 204 | { 205 | "publisher": "Two Peas and Their Pod", 206 | "f2f_url": "http://food2fork.com/view/54427", 207 | "title": "Guacamole Grilled Cheese Sandwich", 208 | "source_url": "http://www.twopeasandtheirpod.com/guacamole-grilled-cheese-sandwich/", 209 | "recipe_id": "54427", 210 | "image_url": "http://static.food2fork.com/GuacamoleGrilledCheese6019.jpg", 211 | "social_rank": 100.0, 212 | "publisher_url": "http://www.twopeasandtheirpod.com" 213 | }, 214 | { 215 | "publisher": "Two Peas and Their Pod", 216 | "f2f_url": "http://food2fork.com/view/54489", 217 | "title": "Two-Ingredient Banana Peanut Butter Ice Cream", 218 | "source_url": "http://www.twopeasandtheirpod.com/two-ingredient-banana-peanut-butter-ice-cream/", 219 | "recipe_id": "54489", 220 | "image_url": "http://static.food2fork.com/bananapeanutbuttericecream5c16d.jpg", 221 | "social_rank": 100.0, 222 | "publisher_url": "http://www.twopeasandtheirpod.com" 223 | }, 224 | { 225 | "publisher": "Simply Recipes", 226 | "f2f_url": "http://food2fork.com/view/36259", 227 | "title": "Easy Shepherd’s Pie", 228 | "source_url": "http://www.simplyrecipes.com/recipes/easy_shepherds_pie/", 229 | "recipe_id": "36259", 230 | "image_url": "http://static.food2fork.com/shepherdspie300x2003d240a98.jpg", 231 | "social_rank": 100.0, 232 | "publisher_url": "http://simplyrecipes.com" 233 | }, 234 | { 235 | "publisher": "Closet Cooking", 236 | "f2f_url": "http://food2fork.com/view/35169", 237 | "title": "Buffalo Chicken Chowder", 238 | "source_url": "http://www.closetcooking.com/2011/11/buffalo-chicken-chowder.html", 239 | "recipe_id": "35169", 240 | "image_url": "http://static.food2fork.com/Buffalo2BChicken2BChowder2B5002B0075c131caa8.jpg", 241 | "social_rank": 100.0, 242 | "publisher_url": "http://closetcooking.com" 243 | }, 244 | { 245 | "publisher": "All Recipes", 246 | "f2f_url": "http://food2fork.com/view/3620", 247 | "title": "Best Brownies", 248 | "source_url": "http://allrecipes.com/Recipe/Best-Brownies/Detail.aspx", 249 | "recipe_id": "3620", 250 | "image_url": "http://static.food2fork.com/720553ee26.jpg", 251 | "social_rank": 100.0, 252 | "publisher_url": "http://allrecipes.com" 253 | }, 254 | { 255 | "publisher": "All Recipes", 256 | "f2f_url": "http://food2fork.com/view/29159", 257 | "title": "Slow Cooker Chicken Tortilla Soup", 258 | "source_url": "http://allrecipes.com/Recipe/Slow-Cooker-Chicken-Tortilla-Soup/Detail.aspx", 259 | "recipe_id": "29159", 260 | "image_url": "http://static.food2fork.com/19321150c4.jpg", 261 | "social_rank": 100.0, 262 | "publisher_url": "http://allrecipes.com" 263 | }, 264 | { 265 | "publisher": "All Recipes", 266 | "f2f_url": "http://food2fork.com/view/2803", 267 | "title": "Banana Crumb Muffins", 268 | "source_url": "http://allrecipes.com/Recipe/Banana-Crumb-Muffins/Detail.aspx", 269 | "recipe_id": "2803", 270 | "image_url": "http://static.food2fork.com/124030cedd.jpg", 271 | "social_rank": 100.0, 272 | "publisher_url": "http://allrecipes.com" 273 | }, 274 | { 275 | "publisher": "All Recipes", 276 | "f2f_url": "http://food2fork.com/view/2734", 277 | "title": "Banana Banana Bread", 278 | "source_url": "http://allrecipes.com/Recipe/Banana-Banana-Bread/Detail.aspx", 279 | "recipe_id": "2734", 280 | "image_url": "http://static.food2fork.com/254186ea50.jpg", 281 | "social_rank": 100.0, 282 | "publisher_url": "http://allrecipes.com" 283 | }, 284 | { 285 | "publisher": "101 Cookbooks", 286 | "f2f_url": "http://food2fork.com/view/47692", 287 | "title": "Nikki", 288 | "source_url": "http://www.101cookbooks.com/archives/nikkis-healthy-cookies-recipe.html", 289 | "recipe_id": "47692", 290 | "image_url": "http://static.food2fork.com/healthy_cookies4ee3.jpg", 291 | "social_rank": 100.0, 292 | "publisher_url": "http://www.101cookbooks.com" 293 | }, 294 | { 295 | "publisher": "All Recipes", 296 | "f2f_url": "http://food2fork.com/view/34889", 297 | "title": "Zesty Slow Cooker Chicken Barbeque", 298 | "source_url": "http://allrecipes.com/Recipe/Zesty-Slow-Cooker-Chicken-Barbecue/Detail.aspx", 299 | "recipe_id": "34889", 300 | "image_url": "http://static.food2fork.com/4515542dbb.jpg", 301 | "social_rank": 100.0, 302 | "publisher_url": "http://allrecipes.com" 303 | } 304 | ] 305 | } -------------------------------------------------------------------------------- /RecipesTests/Fixture/singleRecipe.json: -------------------------------------------------------------------------------- 1 | { 2 | "recipe": { 3 | "publisher": "My Baking Addiction", 4 | "f2f_url": "http://food2fork.com/view/8061c3", 5 | "ingredients": [ 6 | " cup plus 2 tablespoons granulated sugar", 7 | "zest of one lemon", 8 | "2 packages cream cheese, 8 oz each; room temperature", 9 | "2 large eggs; room temperature", 10 | " cup heavy cream" 11 | ], 12 | "source_url": "http://www.mybakingaddiction.com/cheesecake-in-a-jar-recipe/", 13 | "recipe_id": "8061c3", 14 | "image_url": "http://static.food2fork.com/CheesecakeJars2Crop1of1eedb.jpg", 15 | "social_rank": 100.0, 16 | "publisher_url": "http://www.mybakingaddiction.com", 17 | "title": "Virtual Picnic- Cheesecake in a Jar" 18 | } 19 | } -------------------------------------------------------------------------------- /RecipesTests/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 | -------------------------------------------------------------------------------- /RecipesTests/Library/Model/RecipeTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Recipes 3 | 4 | class RecipesTests: XCTestCase { 5 | func testParsing() throws { 6 | let json: [String: Any] = [ 7 | "publisher": "Two Peas and Their Pod", 8 | "f2f_url": "http://food2fork.com/view/975e33", 9 | "title": "No-Bake Chocolate Peanut Butter Pretzel Cookies", 10 | "source_url": "http://www.twopeasandtheirpod.com/no-bake-chocolate-peanut-butter-pretzel-cookies/", 11 | "recipe_id": "975e33", 12 | "image_url": "http://static.food2fork.com/NoBakeChocolatePeanutButterPretzelCookies44147.jpg", 13 | "social_rank": 99.99999999999974, 14 | "publisher_url": "http://www.twopeasandtheirpod.com" 15 | ] 16 | 17 | let data = try JSONSerialization.data(withJSONObject: json, options: []) 18 | let decoder = JSONDecoder() 19 | let recipe = try decoder.decode(Recipe.self, from: data) 20 | 21 | XCTAssertEqual(recipe.title, "No-Bake Chocolate Peanut Butter Pretzel Cookies") 22 | XCTAssertEqual(recipe.id, "975e33") 23 | XCTAssertEqual(recipe.url, URL(string: "http://food2fork.com/view/975e33")!) 24 | } 25 | } 26 | 27 | -------------------------------------------------------------------------------- /RecipesTests/Library/Service/CacheServiceTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CacheServiceTests.swift 3 | // RecipesTests 4 | // 5 | // Created by Khoa Pham on 25.02.2018. 6 | // Copyright © 2018 Khoa Pham. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import Recipes 11 | 12 | class CacheServiceTests: XCTestCase { 13 | let service = CacheService() 14 | 15 | override func setUp() { 16 | super.setUp() 17 | 18 | try? service.clear() 19 | } 20 | 21 | func testClear() { 22 | let expectation = self.expectation(description: #function) 23 | let string = "Hello world" 24 | let data = string.data(using: .utf8)! 25 | 26 | service.save(data: data, key: "key", completion: { 27 | try? self.service.clear() 28 | self.service.load(key: "key", completion: { 29 | XCTAssertNil($0) 30 | expectation.fulfill() 31 | }) 32 | }) 33 | 34 | wait(for: [expectation], timeout: 1) 35 | } 36 | 37 | func testSave() { 38 | let expectation = self.expectation(description: #function) 39 | let string = "Hello world" 40 | let data = string.data(using: .utf8)! 41 | 42 | service.save(data: data, key: "key") 43 | service.load(key: "key", completion: { 44 | XCTAssertEqual($0, data) 45 | expectation.fulfill() 46 | }) 47 | 48 | wait(for: [expectation], timeout: 1) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /RecipesTests/Library/Service/RecipesService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RecipesService.swift 3 | // RecipesTests 4 | // 5 | // Created by Khoa Pham on 25.02.2018. 6 | // Copyright © 2018 Khoa Pham. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import Recipes 11 | 12 | class RecipesServiceTests: XCTestCase { 13 | func testFetchTopRating() { 14 | let expectation = self.expectation(description: #function) 15 | let mockNetworkService = MockNetworkService(fileName: "recipes") 16 | let recipesService = RecipesService(networking: mockNetworkService) 17 | recipesService.fetchTopRating(completion: { recipes in 18 | XCTAssertEqual(recipes.count, 30) 19 | expectation.fulfill() 20 | }) 21 | 22 | wait(for: [expectation], timeout: 1) 23 | } 24 | 25 | func testFetchSingleRecipe() { 26 | let expectation = self.expectation(description: #function) 27 | let mockNetworkService = MockNetworkService(fileName: "singleRecipe") 28 | let recipesService = RecipesService(networking: mockNetworkService) 29 | recipesService.fetch(recipeId: "", completion: { recipe in 30 | XCTAssertEqual(recipe?.ingredients?.count, 5) 31 | expectation.fulfill() 32 | }) 33 | 34 | wait(for: [expectation], timeout: 1) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /RecipesTests/Library/Utils/DebouncerTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DebouncerTests.swift 3 | // RecipesTests 4 | // 5 | // Created by Khoa Pham on 25.02.2018. 6 | // Copyright © 2018 Khoa Pham. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import Recipes 11 | 12 | class DebouncerTests: XCTestCase { 13 | func testDebouncing() { 14 | let cancelExpectation = self.expectation(description: "cancel") 15 | cancelExpectation.isInverted = true 16 | 17 | let completeExpectation = self.expectation(description: "complete") 18 | let debouncer = Debouncer(delay: 0.3) 19 | 20 | debouncer.schedule { 21 | cancelExpectation.fulfill() 22 | } 23 | 24 | debouncer.schedule { 25 | completeExpectation.fulfill() 26 | } 27 | 28 | wait(for: [cancelExpectation, completeExpectation], timeout: 1) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /RecipesTests/Mock/MockNetworkService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockNetworkService.swift 3 | // RecipesTests 4 | // 5 | // Created by Khoa Pham on 25.02.2018. 6 | // Copyright © 2018 Khoa Pham. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | @testable import Recipes 11 | 12 | final class MockNetworkService: Networking { 13 | let data: Data 14 | init(fileName: String) { 15 | let bundle = Bundle(for: MockNetworkService.self) 16 | let url = bundle.url(forResource: fileName, withExtension: "json")! 17 | self.data = try! Data(contentsOf: url) 18 | } 19 | 20 | func fetch(resource: Resource, completion: @escaping (Data?) -> Void) -> URLSessionTask? { 21 | completion(data) 22 | return nil 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /RecipesUITests/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 | -------------------------------------------------------------------------------- /RecipesUITests/RecipesUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RecipesUITests.swift 3 | // RecipesUITests 4 | // 5 | // Created by Khoa Pham on 24.02.2018. 6 | // Copyright © 2018 Khoa Pham. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | class RecipesUITests: XCTestCase { 12 | var app: XCUIApplication! 13 | 14 | override func setUp() { 15 | super.setUp() 16 | continueAfterFailure = false 17 | 18 | app = XCUIApplication() 19 | } 20 | 21 | func testScrolling() { 22 | app.launch() 23 | 24 | let collectionView = app.collectionViews.element(boundBy: 0) 25 | collectionView.swipeUp() 26 | collectionView.swipeUp() 27 | } 28 | 29 | func testGoToDetail() { 30 | app.launch() 31 | 32 | let collectionView = app.collectionViews.element(boundBy: 0) 33 | let firstCell = collectionView.cells.element(boundBy: 0) 34 | firstCell.tap() 35 | } 36 | 37 | func testOpenInstructionInExternalWebPage() { 38 | testGoToDetail() 39 | 40 | let button = app.buttons["View instructions"] 41 | button.tap() 42 | } 43 | 44 | func testSearch() { 45 | app.launch() 46 | 47 | let searchField = app.searchFields.element(boundBy: 0) 48 | searchField.tap() 49 | 50 | searchField.typeText("banana") 51 | 52 | let collectionView = app.collectionViews.element(boundBy: 1) 53 | _ = collectionView.waitForExistence(timeout: 8) 54 | let firstCell = collectionView.cells.element(boundBy: 0) 55 | firstCell.tap() 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Resource/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "size" : "20x20", 5 | "idiom" : "iphone", 6 | "filename" : "icon_20@2x.png", 7 | "scale" : "2x" 8 | }, 9 | { 10 | "size" : "20x20", 11 | "idiom" : "iphone", 12 | "filename" : "icon_20@3x.png", 13 | "scale" : "3x" 14 | }, 15 | { 16 | "size" : "29x29", 17 | "idiom" : "iphone", 18 | "filename" : "icon_29@2x.png", 19 | "scale" : "2x" 20 | }, 21 | { 22 | "size" : "29x29", 23 | "idiom" : "iphone", 24 | "filename" : "icon_29@3x.png", 25 | "scale" : "3x" 26 | }, 27 | { 28 | "size" : "40x40", 29 | "idiom" : "iphone", 30 | "filename" : "icon_40@2x.png", 31 | "scale" : "2x" 32 | }, 33 | { 34 | "size" : "40x40", 35 | "idiom" : "iphone", 36 | "filename" : "icon_40@3x.png", 37 | "scale" : "3x" 38 | }, 39 | { 40 | "size" : "60x60", 41 | "idiom" : "iphone", 42 | "filename" : "icon_60@2x.png", 43 | "scale" : "2x" 44 | }, 45 | { 46 | "size" : "60x60", 47 | "idiom" : "iphone", 48 | "filename" : "icon_60@3x.png", 49 | "scale" : "3x" 50 | }, 51 | { 52 | "size" : "20x20", 53 | "idiom" : "ipad", 54 | "filename" : "icon_20@1x.png", 55 | "scale" : "1x" 56 | }, 57 | { 58 | "size" : "20x20", 59 | "idiom" : "ipad", 60 | "filename" : "icon_20@2x.png", 61 | "scale" : "2x" 62 | }, 63 | { 64 | "size" : "29x29", 65 | "idiom" : "ipad", 66 | "filename" : "icon_29@1x.png", 67 | "scale" : "1x" 68 | }, 69 | { 70 | "size" : "29x29", 71 | "idiom" : "ipad", 72 | "filename" : "icon_29@2x.png", 73 | "scale" : "2x" 74 | }, 75 | { 76 | "size" : "40x40", 77 | "idiom" : "ipad", 78 | "filename" : "icon_40@1x.png", 79 | "scale" : "1x" 80 | }, 81 | { 82 | "size" : "40x40", 83 | "idiom" : "ipad", 84 | "filename" : "icon_40@2x.png", 85 | "scale" : "2x" 86 | }, 87 | { 88 | "size" : "76x76", 89 | "idiom" : "ipad", 90 | "filename" : "icon_76@1x.png", 91 | "scale" : "1x" 92 | }, 93 | { 94 | "size" : "76x76", 95 | "idiom" : "ipad", 96 | "filename" : "icon_76@2x.png", 97 | "scale" : "2x" 98 | }, 99 | { 100 | "size" : "83.5x83.5", 101 | "idiom" : "ipad", 102 | "filename" : "icon_83.5@2x.png", 103 | "scale" : "2x" 104 | }, 105 | { 106 | "size" : "1024x1024", 107 | "idiom" : "ios-marketing", 108 | "filename" : "icon_1024@1x.png", 109 | "scale" : "1x" 110 | } 111 | ], 112 | "info" : { 113 | "version" : 1, 114 | "author" : "xcode" 115 | } 116 | } -------------------------------------------------------------------------------- /Resource/Assets.xcassets/AppIcon.appiconset/icon_1024@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onmyway133/Recipes/a20b0ef6077e3ff9be84e74a1676ee0aed3f7e40/Resource/Assets.xcassets/AppIcon.appiconset/icon_1024@1x.png -------------------------------------------------------------------------------- /Resource/Assets.xcassets/AppIcon.appiconset/icon_20@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onmyway133/Recipes/a20b0ef6077e3ff9be84e74a1676ee0aed3f7e40/Resource/Assets.xcassets/AppIcon.appiconset/icon_20@1x.png -------------------------------------------------------------------------------- /Resource/Assets.xcassets/AppIcon.appiconset/icon_20@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onmyway133/Recipes/a20b0ef6077e3ff9be84e74a1676ee0aed3f7e40/Resource/Assets.xcassets/AppIcon.appiconset/icon_20@2x.png -------------------------------------------------------------------------------- /Resource/Assets.xcassets/AppIcon.appiconset/icon_20@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onmyway133/Recipes/a20b0ef6077e3ff9be84e74a1676ee0aed3f7e40/Resource/Assets.xcassets/AppIcon.appiconset/icon_20@3x.png -------------------------------------------------------------------------------- /Resource/Assets.xcassets/AppIcon.appiconset/icon_29@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onmyway133/Recipes/a20b0ef6077e3ff9be84e74a1676ee0aed3f7e40/Resource/Assets.xcassets/AppIcon.appiconset/icon_29@1x.png -------------------------------------------------------------------------------- /Resource/Assets.xcassets/AppIcon.appiconset/icon_29@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onmyway133/Recipes/a20b0ef6077e3ff9be84e74a1676ee0aed3f7e40/Resource/Assets.xcassets/AppIcon.appiconset/icon_29@2x.png -------------------------------------------------------------------------------- /Resource/Assets.xcassets/AppIcon.appiconset/icon_29@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onmyway133/Recipes/a20b0ef6077e3ff9be84e74a1676ee0aed3f7e40/Resource/Assets.xcassets/AppIcon.appiconset/icon_29@3x.png -------------------------------------------------------------------------------- /Resource/Assets.xcassets/AppIcon.appiconset/icon_40@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onmyway133/Recipes/a20b0ef6077e3ff9be84e74a1676ee0aed3f7e40/Resource/Assets.xcassets/AppIcon.appiconset/icon_40@1x.png -------------------------------------------------------------------------------- /Resource/Assets.xcassets/AppIcon.appiconset/icon_40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onmyway133/Recipes/a20b0ef6077e3ff9be84e74a1676ee0aed3f7e40/Resource/Assets.xcassets/AppIcon.appiconset/icon_40@2x.png -------------------------------------------------------------------------------- /Resource/Assets.xcassets/AppIcon.appiconset/icon_40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onmyway133/Recipes/a20b0ef6077e3ff9be84e74a1676ee0aed3f7e40/Resource/Assets.xcassets/AppIcon.appiconset/icon_40@3x.png -------------------------------------------------------------------------------- /Resource/Assets.xcassets/AppIcon.appiconset/icon_60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onmyway133/Recipes/a20b0ef6077e3ff9be84e74a1676ee0aed3f7e40/Resource/Assets.xcassets/AppIcon.appiconset/icon_60@2x.png -------------------------------------------------------------------------------- /Resource/Assets.xcassets/AppIcon.appiconset/icon_60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onmyway133/Recipes/a20b0ef6077e3ff9be84e74a1676ee0aed3f7e40/Resource/Assets.xcassets/AppIcon.appiconset/icon_60@3x.png -------------------------------------------------------------------------------- /Resource/Assets.xcassets/AppIcon.appiconset/icon_76@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onmyway133/Recipes/a20b0ef6077e3ff9be84e74a1676ee0aed3f7e40/Resource/Assets.xcassets/AppIcon.appiconset/icon_76@1x.png -------------------------------------------------------------------------------- /Resource/Assets.xcassets/AppIcon.appiconset/icon_76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onmyway133/Recipes/a20b0ef6077e3ff9be84e74a1676ee0aed3f7e40/Resource/Assets.xcassets/AppIcon.appiconset/icon_76@2x.png -------------------------------------------------------------------------------- /Resource/Assets.xcassets/AppIcon.appiconset/icon_83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onmyway133/Recipes/a20b0ef6077e3ff9be84e74a1676ee0aed3f7e40/Resource/Assets.xcassets/AppIcon.appiconset/icon_83.5@2x.png -------------------------------------------------------------------------------- /Resource/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /Resource/Assets.xcassets/launchImage.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "scale" : "2x" 10 | }, 11 | { 12 | "idiom" : "universal", 13 | "filename" : "Spoon.jpg", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | } 21 | } -------------------------------------------------------------------------------- /Resource/Assets.xcassets/launchImage.imageset/Spoon.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onmyway133/Recipes/a20b0ef6077e3ff9be84e74a1676ee0aed3f7e40/Resource/Assets.xcassets/launchImage.imageset/Spoon.jpg -------------------------------------------------------------------------------- /Resource/Assets.xcassets/notFound.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "scale" : "2x" 10 | }, 11 | { 12 | "idiom" : "universal", 13 | "filename" : "notFound.png", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | } 21 | } -------------------------------------------------------------------------------- /Resource/Assets.xcassets/notFound.imageset/notFound.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onmyway133/Recipes/a20b0ef6077e3ff9be84e74a1676ee0aed3f7e40/Resource/Assets.xcassets/notFound.imageset/notFound.png -------------------------------------------------------------------------------- /Resource/Assets.xcassets/recipePlaceholder.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "scale" : "2x" 10 | }, 11 | { 12 | "idiom" : "universal", 13 | "filename" : "recipePlaceholder.jpg", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | } 21 | } -------------------------------------------------------------------------------- /Resource/Assets.xcassets/recipePlaceholder.imageset/recipePlaceholder.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onmyway133/Recipes/a20b0ef6077e3ff9be84e74a1676ee0aed3f7e40/Resource/Assets.xcassets/recipePlaceholder.imageset/recipePlaceholder.jpg -------------------------------------------------------------------------------- /Resource/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 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /Resource/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 | APPL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UILaunchStoryboardName 24 | LaunchScreen 25 | UIRequiredDeviceCapabilities 26 | 27 | armv7 28 | 29 | UISupportedInterfaceOrientations 30 | 31 | UIInterfaceOrientationPortrait 32 | 33 | UISupportedInterfaceOrientations~ipad 34 | 35 | UIInterfaceOrientationPortrait 36 | UIInterfaceOrientationPortraitUpsideDown 37 | UIInterfaceOrientationLandscapeLeft 38 | UIInterfaceOrientationLandscapeRight 39 | 40 | NSAppTransportSecurity 41 | 42 | NSExceptionDomains 43 | 44 | food2fork.com 45 | 46 | NSIncludesSubdomains 47 | 48 | NSExceptionAllowsInsecureHTTPLoads 49 | 50 | 51 | 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /Screenshots/AppIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onmyway133/Recipes/a20b0ef6077e3ff9be84e74a1676ee0aed3f7e40/Screenshots/AppIcon.png -------------------------------------------------------------------------------- /Screenshots/AppStore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onmyway133/Recipes/a20b0ef6077e3ff9be84e74a1676ee0aed3f7e40/Screenshots/AppStore.png -------------------------------------------------------------------------------- /Screenshots/Detail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onmyway133/Recipes/a20b0ef6077e3ff9be84e74a1676ee0aed3f7e40/Screenshots/Detail.png -------------------------------------------------------------------------------- /Screenshots/Home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onmyway133/Recipes/a20b0ef6077e3ff9be84e74a1676ee0aed3f7e40/Screenshots/Home.png -------------------------------------------------------------------------------- /Screenshots/Insomnia.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onmyway133/Recipes/a20b0ef6077e3ff9be84e74a1676ee0aed3f7e40/Screenshots/Insomnia.png -------------------------------------------------------------------------------- /Screenshots/LaunchScreen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onmyway133/Recipes/a20b0ef6077e3ff9be84e74a1676ee0aed3f7e40/Screenshots/LaunchScreen.png -------------------------------------------------------------------------------- /Screenshots/MARK.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onmyway133/Recipes/a20b0ef6077e3ff9be84e74a1676ee0aed3f7e40/Screenshots/MARK.png -------------------------------------------------------------------------------- /Screenshots/MainGuard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onmyway133/Recipes/a20b0ef6077e3ff9be84e74a1676ee0aed3f7e40/Screenshots/MainGuard.png -------------------------------------------------------------------------------- /Screenshots/Measurement.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onmyway133/Recipes/a20b0ef6077e3ff9be84e74a1676ee0aed3f7e40/Screenshots/Measurement.png -------------------------------------------------------------------------------- /Screenshots/Project.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onmyway133/Recipes/a20b0ef6077e3ff9be84e74a1676ee0aed3f7e40/Screenshots/Project.png -------------------------------------------------------------------------------- /Screenshots/SwiftLint.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onmyway133/Recipes/a20b0ef6077e3ff9be84e74a1676ee0aed3f7e40/Screenshots/SwiftLint.png -------------------------------------------------------------------------------- /Source/Feature/Detail/RecipeDetailView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RecipeDetailView.swift 3 | // Recipes 4 | // 5 | // Created by Khoa Pham on 25.02.2018. 6 | // Copyright © 2018 Khoa Pham. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | final class RecipeDetailView: UIView { 12 | private let scrollableView = ScrollableView() 13 | private(set) lazy var imageView: UIImageView = self.makeImageView() 14 | private(set) lazy var ingredientLabel: UILabel = self.makeIngredientLabel() 15 | private(set) lazy var instructionButton: UIButton = self.makeButton() 16 | private(set) lazy var originalButton: UIButton = self.makeButton() 17 | private(set) lazy var infoView: InfoView = InfoView() 18 | 19 | override init(frame: CGRect) { 20 | super.init(frame: frame) 21 | 22 | setupConstraints() 23 | } 24 | 25 | required init?(coder aDecoder: NSCoder) { 26 | fatalError() 27 | } 28 | 29 | private func setupConstraints() { 30 | let ingredientHeaderView = HeaderView(text: "Ingredients") 31 | let infoHeaderView = HeaderView(text: "Info") 32 | instructionButton.setTitle("View instructions", for: .normal) 33 | originalButton.setTitle("View original", for: .normal) 34 | 35 | addSubview(scrollableView) 36 | NSLayoutConstraint.pin(view: scrollableView, toEdgesOf: self) 37 | 38 | scrollableView.setup(pairs: [ 39 | ScrollableView.Pair(view: imageView, inset: UIEdgeInsets(top: 8, left: 0, bottom: 0, right: 0)), 40 | ScrollableView.Pair(view: ingredientHeaderView, inset: UIEdgeInsets(top: 8, left: 0, bottom: 0, right: 0)), 41 | ScrollableView.Pair(view: ingredientLabel, inset: UIEdgeInsets(top: 4, left: 8, bottom: 0, right: 0)), 42 | ScrollableView.Pair(view: infoHeaderView, inset: UIEdgeInsets(top: 4, left: 0, bottom: 0, right: 0)), 43 | ScrollableView.Pair(view: instructionButton, inset: UIEdgeInsets(top: 8, left: 20, bottom: 0, right: 20)), 44 | ScrollableView.Pair(view: originalButton, inset: UIEdgeInsets(top: 8, left: 20, bottom: 0, right: 20)), 45 | ScrollableView.Pair(view: infoView, inset: UIEdgeInsets(top: 16, left: 0, bottom: 20, right: 0)) 46 | ]) 47 | 48 | NSLayoutConstraint.activate([ 49 | imageView.heightAnchor.constraint(equalTo: imageView.widthAnchor, multiplier: 9.0/16), 50 | 51 | ingredientHeaderView.heightAnchor.constraint(equalToConstant: 30), 52 | infoHeaderView.heightAnchor.constraint(equalToConstant: 30), 53 | 54 | instructionButton.heightAnchor.constraint(equalToConstant: 50), 55 | originalButton.heightAnchor.constraint(equalToConstant: 50) 56 | ]) 57 | } 58 | 59 | // MARK: - Make 60 | 61 | private func makeImageView() -> UIImageView { 62 | let imageView = UIImageView() 63 | imageView.contentMode = .scaleAspectFill 64 | imageView.clipsToBounds = true 65 | return imageView 66 | } 67 | 68 | private func makeIngredientLabel() -> UILabel { 69 | let label = UILabel() 70 | label.numberOfLines = 0 71 | return label 72 | } 73 | 74 | private func makeButton() -> UIButton { 75 | let button = UIButton() 76 | button.backgroundColor = Color.main 77 | button.layer.cornerRadius = 10 78 | button.titleLabel?.font = UIFont.preferredFont(forTextStyle: .headline) 79 | button.setTitleColor(.darkGray, for: .highlighted) 80 | 81 | return button 82 | } 83 | } 84 | 85 | private final class HeaderView: UIView { 86 | private(set) lazy var label: UILabel = self.makeLabel() 87 | 88 | required init(text: String) { 89 | super.init(frame: .zero) 90 | label.text = text 91 | addSubview(label) 92 | backgroundColor = Color.main.withAlphaComponent(0.8) 93 | 94 | setupConstraints() 95 | } 96 | 97 | required init?(coder aDecoder: NSCoder) { 98 | fatalError() 99 | } 100 | 101 | override func layoutSubviews() { 102 | super.layoutSubviews() 103 | 104 | round(corners: .topRight) 105 | } 106 | 107 | // MARK: - Make 108 | 109 | private func makeLabel() -> UILabel { 110 | let label = UILabel() 111 | label.font = UIFont.preferredFont(forTextStyle: .headline) 112 | return label 113 | } 114 | 115 | private func setupConstraints() { 116 | NSLayoutConstraint.activate([ 117 | label.centerYAnchor.constraint(equalTo: centerYAnchor), 118 | label.leftAnchor.constraint(equalTo: leftAnchor, constant: 8) 119 | ]) 120 | } 121 | } 122 | 123 | final class InfoView: UIView { 124 | private(set) lazy var leftLabel: UILabel = self.makeLabel() 125 | private(set) lazy var rightLabel: UILabel = self.makeLabel() 126 | 127 | override init(frame: CGRect) { 128 | super.init(frame: frame) 129 | 130 | addSubviews([leftLabel, rightLabel]) 131 | rightLabel.textAlignment = .right 132 | setupConstraints() 133 | } 134 | 135 | required init?(coder aDecoder: NSCoder) { 136 | fatalError() 137 | } 138 | 139 | // MARK: - Make 140 | 141 | private func makeLabel() -> UILabel { 142 | let label = UILabel() 143 | label.font = UIFont.preferredFont(forTextStyle: .footnote) 144 | 145 | return label 146 | } 147 | 148 | private func setupConstraints() { 149 | NSLayoutConstraint.activate([ 150 | leftLabel.leftAnchor.constraint(equalTo: leftAnchor, constant: 8), 151 | rightLabel.rightAnchor.constraint(equalTo: rightAnchor, constant: -8), 152 | 153 | leftLabel.centerYAnchor.constraint(equalTo: centerYAnchor), 154 | rightLabel.centerYAnchor.constraint(equalTo: centerYAnchor) 155 | ]) 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /Source/Feature/Detail/RecipeDetailViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RecipeDetailViewController.swift 3 | // Recipes 4 | // 5 | // Created by Khoa Pham on 24.02.2018. 6 | // Copyright © 2018 Khoa Pham. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | /// Show detail information for a recipe 12 | final class RecipeDetailViewController: BaseController { 13 | private let recipe: Recipe 14 | private let recipesService: RecipesService 15 | 16 | var selectInstruction: ((URL) -> Void)? 17 | var selectOriginal: ((URL) -> Void)? 18 | 19 | // MARK: - Init 20 | 21 | required init(recipe: Recipe, recipesService: RecipesService) { 22 | self.recipe = recipe 23 | self.recipesService = recipesService 24 | super.init(nibName: nil, bundle: nil) 25 | self.title = recipe.title 26 | } 27 | 28 | required init?(coder aDecoder: NSCoder) { 29 | fatalError() 30 | } 31 | 32 | // MARK: - Life cycle 33 | 34 | override func viewDidLoad() { 35 | super.viewDidLoad() 36 | 37 | view.backgroundColor = Color.background 38 | setup() 39 | update(recipe: recipe) 40 | loadData() 41 | } 42 | 43 | private func setup() { 44 | root.instructionButton.addTarget(self, action: #selector(instructionButtonTouched), for: .touchUpInside) 45 | root.originalButton.addTarget(self, action: #selector(originalButtonTouched), for: .touchUpInside) 46 | } 47 | 48 | private func update(recipe: Recipe) { 49 | root.imageView.setImage(url: recipe.imageUrl, placeholder: R.image.recipePlaceholder()) 50 | root.infoView.leftLabel.text = recipe.publisher 51 | root.infoView.rightLabel.text = "Social rank: \(Int(recipe.socialRank))" 52 | 53 | if let ingredients = recipe.ingredients { 54 | let text = ingredients 55 | .map(({ "🍭 \($0)" })) 56 | .joined(separator: "\n") 57 | 58 | UIView.transition( 59 | with: root, 60 | duration: 0.25, 61 | options: .transitionCrossDissolve, 62 | animations: { 63 | self.root.ingredientLabel.text = text 64 | }, 65 | completion: nil 66 | ) 67 | } 68 | } 69 | 70 | // MARK: - Action 71 | 72 | @objc private func instructionButtonTouched() { 73 | selectInstruction?(recipe.sourceUrl) 74 | } 75 | 76 | @objc private func originalButtonTouched() { 77 | selectOriginal?(recipe.publisherUrl) 78 | } 79 | 80 | // MARK: - Data 81 | 82 | private func loadData() { 83 | recipesService.fetch(recipeId: recipe.id, completion: { [weak self] recipe in 84 | if let recipe = recipe { 85 | self?.update(recipe: recipe) 86 | } 87 | }) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /Source/Feature/Home/HomeViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HomeViewController.swift 3 | // Recipes 4 | // 5 | // Created by Khoa Pham on 25.02.2018. 6 | // Copyright © 2018 Khoa Pham. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | /// Show a list of recipes 12 | final class HomeViewController: UIViewController { 13 | 14 | /// When a recipe get select 15 | var select: ((Recipe) -> Void)? 16 | 17 | private var refreshControl = UIRefreshControl() 18 | private let recipesService: RecipesService 19 | private let searchComponent: SearchComponent 20 | private let recipeListViewController = RecipeListViewController() 21 | 22 | // MARK: - Init 23 | 24 | required init(recipesService: RecipesService) { 25 | self.recipesService = recipesService 26 | self.searchComponent = SearchComponent(recipesService: recipesService) 27 | super.init(nibName: nil, bundle: nil) 28 | self.title = "Recipes" 29 | } 30 | 31 | required init?(coder aDecoder: NSCoder) { 32 | fatalError() 33 | } 34 | 35 | // MARK: - View life Cycle 36 | 37 | override func viewDidLoad() { 38 | super.viewDidLoad() 39 | 40 | setup() 41 | setupSearch() 42 | loadData() 43 | } 44 | 45 | // MARK: - Setup 46 | 47 | private func setup() { 48 | recipeListViewController.adapter.select = select 49 | add(childViewController: recipeListViewController) 50 | recipeListViewController.collectionView.addSubview(refreshControl) 51 | refreshControl.addTarget(self, action: #selector(refresh), for: .valueChanged) 52 | } 53 | 54 | private func setupSearch() { 55 | searchComponent.add(to: self) 56 | searchComponent.recipeListViewController.adapter.select = select 57 | } 58 | 59 | // MARK: - Action 60 | 61 | @objc private func refresh() { 62 | loadData() 63 | } 64 | 65 | // MARK: - Data 66 | 67 | private func loadData() { 68 | refreshControl.beginRefreshing() 69 | recipesService.fetchTopRating(completion: { [weak self] recipes in 70 | self?.recipeListViewController.handle(recipes: recipes) 71 | self?.refreshControl.endRefreshing() 72 | }) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Source/Feature/List/RecipeCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RecipeCell.swift 3 | // Recipes 4 | // 5 | // Created by Khoa Pham on 24.02.2018. 6 | // Copyright © 2018 Khoa Pham. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | final class RecipeCell: UICollectionViewCell { 12 | private(set) lazy var containerView: UIView = { 13 | let view = UIView() 14 | view.clipsToBounds = true 15 | view.layer.cornerRadius = 5 16 | view.backgroundColor = Color.main.withAlphaComponent(0.4) 17 | 18 | return view 19 | }() 20 | 21 | private(set) lazy var imageView: UIImageView = { 22 | let imageView = UIImageView() 23 | imageView.contentMode = .scaleAspectFill 24 | imageView.clipsToBounds = true 25 | 26 | return imageView 27 | }() 28 | 29 | private(set) lazy var label: UILabel = { 30 | let label = UILabel() 31 | label.numberOfLines = 2 32 | label.font = UIFont.preferredFont(forTextStyle: .headline) 33 | 34 | return label 35 | }() 36 | 37 | override init(frame: CGRect) { 38 | super.init(frame: frame) 39 | 40 | addSubview(containerView) 41 | containerView.addSubview(imageView) 42 | containerView.addSubview(label) 43 | 44 | setupConstraints() 45 | } 46 | 47 | required init?(coder aDecoder: NSCoder) { 48 | fatalError() 49 | } 50 | 51 | private func setupConstraints() { 52 | NSLayoutConstraint.activate([ 53 | containerView.leftAnchor.constraint(equalTo: leftAnchor, constant: 8), 54 | containerView.rightAnchor.constraint(equalTo: rightAnchor, constant: -8), 55 | containerView.topAnchor.constraint(equalTo: topAnchor, constant: 1), 56 | containerView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -1), 57 | 58 | imageView.leftAnchor.constraint(equalTo: containerView.leftAnchor, constant: 4), 59 | imageView.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 4), 60 | imageView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: -4), 61 | imageView.widthAnchor.constraint(equalTo: imageView.heightAnchor, multiplier: 1), 62 | 63 | label.centerYAnchor.constraint(equalTo: containerView.centerYAnchor), 64 | label.leftAnchor.constraint(equalTo: imageView.rightAnchor, constant: 8), 65 | label.rightAnchor.constraint(equalTo: containerView.rightAnchor, constant: -8) 66 | ]) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Source/Feature/List/RecipeListViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RecipeListViewController 3 | // Recipes 4 | // 5 | // Created by Khoa Pham on 24.02.2018. 6 | // Copyright © 2018 Khoa Pham. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | /// Show a list of recipes 12 | final class RecipeListViewController: UIViewController { 13 | private(set) var collectionView: UICollectionView! 14 | let adapter = Adapter() 15 | private let emptyView = EmptyView(text: "No recipes found!") 16 | 17 | // MARK: - Life Cycle 18 | 19 | override func viewDidLoad() { 20 | super.viewDidLoad() 21 | 22 | setup() 23 | } 24 | 25 | private func setup() { 26 | view.backgroundColor = .white 27 | 28 | let layout = UICollectionViewFlowLayout() 29 | collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) 30 | collectionView.dataSource = adapter 31 | collectionView.delegate = adapter 32 | collectionView.register(cellType: RecipeCell.self) 33 | collectionView.backgroundColor = Color.background 34 | collectionView.contentInset.top = 8 35 | collectionView.alwaysBounceVertical = true 36 | 37 | adapter.configure = { recipe, cell in 38 | cell.imageView.setImage(url: recipe.imageUrl, placeholder: R.image.recipePlaceholder()) 39 | cell.label.text = recipe.title 40 | } 41 | 42 | view.addSubview(collectionView) 43 | NSLayoutConstraint.pin(view: collectionView, toEdgesOf: view) 44 | 45 | view.addSubview(emptyView) 46 | NSLayoutConstraint.pin(view: emptyView, toEdgesOf: view) 47 | emptyView.alpha = 0 48 | } 49 | 50 | func handle(recipes: [Recipe]) { 51 | adapter.items = recipes 52 | collectionView.reloadData() 53 | 54 | UIView.animate(withDuration: 0.25, animations: { 55 | self.emptyView.alpha = recipes.isEmpty ? 1 : 0 56 | }) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Source/Feature/Search/SearchComponent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchComponent.swift 3 | // Recipes 4 | // 5 | // Created by Khoa Pham on 25.02.2018. 6 | // Copyright © 2018 Khoa Pham. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | /// A reusable component to add search functionality via search bar 12 | final class SearchComponent: NSObject, UISearchResultsUpdating, UISearchBarDelegate { 13 | let recipesService: RecipesService 14 | let searchController: UISearchController 15 | let recipeListViewController = RecipeListViewController() 16 | var task: URLSessionTask? 17 | private lazy var loadingIndicator: UIActivityIndicatorView = self.makeLoadingIndicator() 18 | 19 | /// Avoid making lots of requests when user types fast 20 | /// This throttles the search requests 21 | let debouncer = Debouncer(delay: 2) 22 | 23 | required init(recipesService: RecipesService) { 24 | self.recipesService = recipesService 25 | self.searchController = UISearchController(searchResultsController: recipeListViewController) 26 | super.init() 27 | searchController.searchResultsUpdater = self 28 | searchController.searchBar.delegate = self 29 | searchController.dimsBackgroundDuringPresentation = true 30 | searchController.hidesNavigationBarDuringPresentation = false 31 | searchController.searchBar.placeholder = "Search recipe" 32 | 33 | recipeListViewController.view.addSubview(loadingIndicator) 34 | NSLayoutConstraint.activate([ 35 | loadingIndicator.centerXAnchor.constraint(equalTo: recipeListViewController.view.centerXAnchor), 36 | loadingIndicator.centerYAnchor.constraint(equalTo: recipeListViewController.view.centerYAnchor, constant: -100) 37 | ]) 38 | } 39 | 40 | /// Add search bar to view controller 41 | func add(to viewController: UIViewController) { 42 | if #available(iOS 11, *) { 43 | viewController.navigationItem.searchController = searchController 44 | viewController.navigationItem.hidesSearchBarWhenScrolling = false 45 | } else { 46 | viewController.navigationItem.titleView = searchController.searchBar 47 | } 48 | 49 | viewController.definesPresentationContext = true 50 | } 51 | 52 | // MARK: - UISearchResultsUpdating 53 | 54 | func updateSearchResults(for searchController: UISearchController) { 55 | // No op 56 | } 57 | 58 | // MARK: - UISearchBarDelegate 59 | 60 | func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { 61 | debouncer.schedule { [weak self] in 62 | self?.performSearch() 63 | } 64 | } 65 | 66 | // MARK: - Logic 67 | 68 | private func performSearch() { 69 | guard let text = searchController.searchBar.text, !text.isEmpty else { 70 | return 71 | } 72 | 73 | search(query: text) 74 | } 75 | 76 | private func search(query: String) { 77 | task?.cancel() 78 | loadingIndicator.startAnimating() 79 | task = recipesService.search(query: query, completion: { [weak self] recipes in 80 | self?.loadingIndicator.stopAnimating() 81 | self?.recipeListViewController.handle(recipes: recipes) 82 | }) 83 | } 84 | 85 | // MARK: - Make 86 | 87 | private func makeLoadingIndicator() -> UIActivityIndicatorView { 88 | let view = UIActivityIndicatorView(activityIndicatorStyle: .whiteLarge) 89 | view.color = .darkGray 90 | view.hidesWhenStopped = true 91 | return view 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /Source/Library/Adapter/Adapter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Adapter.swift 3 | // Recipes 4 | // 5 | // Created by Khoa Pham on 24.02.2018. 6 | // Copyright © 2018 Khoa Pham. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | /// A generic adapter to act as convenient DataSource and Delegate for UICollectionView 12 | final class Adapter: NSObject, 13 | UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { 14 | var items: [T] = [] 15 | var configure: ((T, Cell) -> Void)? 16 | var select: ((T) -> Void)? 17 | var cellHeight: CGFloat = 60 18 | 19 | func numberOfSections(in collectionView: UICollectionView) -> Int { 20 | return 1 21 | } 22 | 23 | func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { 24 | return items.count 25 | } 26 | 27 | func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { 28 | let item = items[indexPath.item] 29 | 30 | let cell: Cell = collectionView.dequeue(indexPath: indexPath) 31 | configure?(item, cell) 32 | return cell 33 | } 34 | 35 | func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { 36 | let item = items[indexPath.item] 37 | select?(item) 38 | } 39 | 40 | func collectionView( 41 | _ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, 42 | sizeForItemAt indexPath: IndexPath) -> CGSize { 43 | 44 | return CGSize(width: collectionView.frame.size.width, height: cellHeight) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Source/Library/App/AppConfig.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppConfig.swift 3 | // Recipes 4 | // 5 | // Created by Khoa Pham on 25.02.2018. 6 | // Copyright © 2018 Khoa Pham. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// Configurations and keys for this app 12 | struct AppConfig { 13 | 14 | /// API key for food2fork 15 | static let apiKey = "f1c105dd473738d96661a5a644ba4815" 16 | } 17 | -------------------------------------------------------------------------------- /Source/Library/App/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // Recipes 4 | // 5 | // Created by Khoa Pham on 24.02.2018. 6 | // Copyright © 2018 Khoa Pham. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | @UIApplicationMain 12 | class AppDelegate: UIResponder, UIApplicationDelegate { 13 | 14 | var window: UIWindow? 15 | let appFlowController = AppFlowController() 16 | 17 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { 18 | 19 | window = UIWindow(frame: UIScreen.main.bounds) 20 | window?.rootViewController = appFlowController 21 | window?.makeKeyAndVisible() 22 | 23 | appFlowController.start() 24 | 25 | return true 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Source/Library/App/Color.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Color.swift 3 | // Recipes 4 | // 5 | // Created by Khoa Pham on 25.02.2018. 6 | // Copyright © 2018 Khoa Pham. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | /// App color 12 | struct Color { 13 | static let background = UIColor(hex: "#ecf0f1") 14 | static let main = UIColor(hex: "#9E6BE0") 15 | } 16 | -------------------------------------------------------------------------------- /Source/Library/Base/BaseController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BaseController.swift 3 | // Recipes 4 | // 5 | // Created by Khoa Pham on 25.02.2018. 6 | // Copyright © 2018 Khoa Pham. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | /// Used to separate between controller and view 12 | class BaseController: UIViewController { 13 | let root = T() 14 | 15 | override func loadView() { 16 | view = root 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Source/Library/Constants/R.generated.swift: -------------------------------------------------------------------------------- 1 | // 2 | // This is a generated file, do not edit! 3 | // Generated by R.swift, see https://github.com/mac-cain13/R.swift 4 | // 5 | 6 | import Foundation 7 | import Rswift 8 | import UIKit 9 | 10 | /// This `R` struct is generated and contains references to static resources. 11 | struct R: Rswift.Validatable { 12 | fileprivate static let applicationLocale = hostingBundle.preferredLocalizations.first.flatMap(Locale.init) ?? Locale.current 13 | fileprivate static let hostingBundle = Bundle(for: R.Class.self) 14 | 15 | static func validate() throws { 16 | try intern.validate() 17 | } 18 | 19 | /// This `R.color` struct is generated, and contains static references to 0 colors. 20 | struct color { 21 | fileprivate init() {} 22 | } 23 | 24 | /// This `R.file` struct is generated, and contains static references to 0 files. 25 | struct file { 26 | fileprivate init() {} 27 | } 28 | 29 | /// This `R.font` struct is generated, and contains static references to 0 fonts. 30 | struct font { 31 | fileprivate init() {} 32 | } 33 | 34 | /// This `R.image` struct is generated, and contains static references to 3 images. 35 | struct image { 36 | /// Image `launchImage`. 37 | static let launchImage = Rswift.ImageResource(bundle: R.hostingBundle, name: "launchImage") 38 | /// Image `notFound`. 39 | static let notFound = Rswift.ImageResource(bundle: R.hostingBundle, name: "notFound") 40 | /// Image `recipePlaceholder`. 41 | static let recipePlaceholder = Rswift.ImageResource(bundle: R.hostingBundle, name: "recipePlaceholder") 42 | 43 | /// `UIImage(named: "launchImage", bundle: ..., traitCollection: ...)` 44 | static func launchImage(compatibleWith traitCollection: UIKit.UITraitCollection? = nil) -> UIKit.UIImage? { 45 | return UIKit.UIImage(resource: R.image.launchImage, compatibleWith: traitCollection) 46 | } 47 | 48 | /// `UIImage(named: "notFound", bundle: ..., traitCollection: ...)` 49 | static func notFound(compatibleWith traitCollection: UIKit.UITraitCollection? = nil) -> UIKit.UIImage? { 50 | return UIKit.UIImage(resource: R.image.notFound, compatibleWith: traitCollection) 51 | } 52 | 53 | /// `UIImage(named: "recipePlaceholder", bundle: ..., traitCollection: ...)` 54 | static func recipePlaceholder(compatibleWith traitCollection: UIKit.UITraitCollection? = nil) -> UIKit.UIImage? { 55 | return UIKit.UIImage(resource: R.image.recipePlaceholder, compatibleWith: traitCollection) 56 | } 57 | 58 | fileprivate init() {} 59 | } 60 | 61 | /// This `R.nib` struct is generated, and contains static references to 0 nibs. 62 | struct nib { 63 | fileprivate init() {} 64 | } 65 | 66 | /// This `R.reuseIdentifier` struct is generated, and contains static references to 0 reuse identifiers. 67 | struct reuseIdentifier { 68 | fileprivate init() {} 69 | } 70 | 71 | /// This `R.segue` struct is generated, and contains static references to 0 view controllers. 72 | struct segue { 73 | fileprivate init() {} 74 | } 75 | 76 | /// This `R.storyboard` struct is generated, and contains static references to 1 storyboards. 77 | struct storyboard { 78 | /// Storyboard `LaunchScreen`. 79 | static let launchScreen = _R.storyboard.launchScreen() 80 | 81 | /// `UIStoryboard(name: "LaunchScreen", bundle: ...)` 82 | static func launchScreen(_: Void = ()) -> UIKit.UIStoryboard { 83 | return UIKit.UIStoryboard(resource: R.storyboard.launchScreen) 84 | } 85 | 86 | fileprivate init() {} 87 | } 88 | 89 | /// This `R.string` struct is generated, and contains static references to 0 localization tables. 90 | struct string { 91 | fileprivate init() {} 92 | } 93 | 94 | fileprivate struct intern: Rswift.Validatable { 95 | fileprivate static func validate() throws { 96 | try _R.validate() 97 | } 98 | 99 | fileprivate init() {} 100 | } 101 | 102 | fileprivate class Class {} 103 | 104 | fileprivate init() {} 105 | } 106 | 107 | struct _R: Rswift.Validatable { 108 | static func validate() throws { 109 | try storyboard.validate() 110 | } 111 | 112 | struct nib { 113 | fileprivate init() {} 114 | } 115 | 116 | struct storyboard: Rswift.Validatable { 117 | static func validate() throws { 118 | try launchScreen.validate() 119 | } 120 | 121 | struct launchScreen: Rswift.StoryboardResourceWithInitialControllerType, Rswift.Validatable { 122 | typealias InitialController = UIKit.UIViewController 123 | 124 | let bundle = R.hostingBundle 125 | let name = "LaunchScreen" 126 | 127 | static func validate() throws { 128 | if UIKit.UIImage(named: "launchImage") == nil { throw Rswift.ValidationError(description: "[R.swift] Image named 'launchImage' is used in storyboard 'LaunchScreen', but couldn't be loaded.") } 129 | } 130 | 131 | fileprivate init() {} 132 | } 133 | 134 | fileprivate init() {} 135 | } 136 | 137 | fileprivate init() {} 138 | } 139 | -------------------------------------------------------------------------------- /Source/Library/Extensions/NSLayoutConstraint+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSLayoutConstraint+Extensions.swift 3 | // Recipes 4 | // 5 | // Created by Khoa Pham on 24.02.2018. 6 | // Copyright © 2018 Khoa Pham. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension NSLayoutConstraint { 12 | /// Activate constraints 13 | /// 14 | /// - Parameter constraints: An array of constraints 15 | static func activate(_ constraints: [NSLayoutConstraint]) { 16 | constraints.forEach { 17 | ($0.firstItem as? UIView)?.translatesAutoresizingMaskIntoConstraints = false 18 | $0.isActive = true 19 | } 20 | } 21 | 22 | static func pin(view: UIView, toEdgesOf anotherView: UIView) { 23 | activate([ 24 | view.topAnchor.constraint(equalTo: anotherView.topAnchor), 25 | view.leftAnchor.constraint(equalTo: anotherView.leftAnchor), 26 | view.rightAnchor.constraint(equalTo: anotherView.rightAnchor), 27 | view.bottomAnchor.constraint(equalTo: anotherView.bottomAnchor) 28 | ]) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Source/Library/Extensions/UICollectionView+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UICollectionView+Extensions.swift 3 | // Recipes 4 | // 5 | // Created by Khoa Pham on 25.02.2018. 6 | // Copyright © 2018 Khoa Pham. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension UICollectionView { 12 | 13 | /// Generic function to dequeue cell 14 | func dequeue(indexPath: IndexPath) -> Cell { 15 | // swiftlint:disable force_cast 16 | return dequeueReusableCell(withReuseIdentifier: String(describing: Cell.self), for: indexPath) as! Cell 17 | } 18 | 19 | /// Generic function to register cell 20 | func register(cellType: UICollectionViewCell.Type) { 21 | register(cellType, forCellWithReuseIdentifier: String(describing: cellType.self)) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Source/Library/Extensions/UIColor+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIColor+Extensions.swift 3 | // Recipes 4 | // 5 | // Created by Khoa Pham on 24.02.2018. 6 | // Copyright © 2018 Khoa Pham. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension UIColor { 12 | /// Init color from hex string 13 | /// 14 | /// - Parameter hex: A hex string, can start with or without # 15 | convenience init(hex: String) { 16 | let hex = hex.replacingOccurrences(of: "#", with: "") 17 | 18 | // Need 6 characters 19 | guard hex.count == 6 else { 20 | self.init(white: 1.0, alpha: 1.0) 21 | return 22 | } 23 | 24 | self.init( 25 | red: CGFloat((Int(hex, radix: 16)! >> 16) & 0xFF) / 255.0, 26 | green: CGFloat((Int(hex, radix: 16)! >> 8) & 0xFF) / 255.0, 27 | blue: CGFloat((Int(hex, radix: 16)!) & 0xFF) / 255.0, alpha: 1.0 28 | ) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Source/Library/Extensions/UIImageView+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIImageView+Extensions.swift 3 | // Recipes 4 | // 5 | // Created by Khoa Pham on 24.02.2018. 6 | // Copyright © 2018 Khoa Pham. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension UIImageView { 12 | /// Used to set image from an url 13 | /// 14 | /// - Parameter url: url for download 15 | func setImage(url: URL, placeholder: UIImage? = nil) { 16 | if imageService == nil { 17 | imageService = ImageService(networkService: NetworkService(), cacheService: CacheService()) 18 | } 19 | 20 | self.image = placeholder 21 | self.imageService?.fetch(url: url, completion: { [weak self] image in 22 | self?.image = image 23 | }) 24 | } 25 | 26 | /// Use for store property 27 | private var imageService: ImageService? { 28 | get { 29 | return objc_getAssociatedObject(self, &AssociateKey.imageService) as? ImageService 30 | } 31 | set { 32 | objc_setAssociatedObject( 33 | self, 34 | &AssociateKey.imageService, 35 | newValue, 36 | objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC 37 | ) 38 | } 39 | } 40 | } 41 | 42 | fileprivate struct AssociateKey { 43 | static var imageService = 0 44 | } 45 | -------------------------------------------------------------------------------- /Source/Library/Extensions/UIView+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIView+Extensions.swift 3 | // Recipes 4 | // 5 | // Created by Khoa Pham on 25.02.2018. 6 | // Copyright © 2018 Khoa Pham. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension UIView { 12 | 13 | /// Convenient method to add a list of subviews 14 | func addSubviews(_ views: [UIView]) { 15 | views.forEach({ 16 | addSubview($0) 17 | }) 18 | } 19 | 20 | /// Apply mask to round corners 21 | func round(corners: UIRectCorner) { 22 | let raddi = bounds.size.height / 2 23 | let path = UIBezierPath( 24 | roundedRect: bounds, 25 | byRoundingCorners: corners, 26 | cornerRadii: CGSize(width: raddi, height: raddi) 27 | ) 28 | 29 | let maskLayer = CAShapeLayer() 30 | 31 | maskLayer.path = path.cgPath 32 | layer.mask = maskLayer 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Source/Library/Extensions/UIViewController+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIViewController+Extensions.swift 3 | // Recipes 4 | // 5 | // Created by Khoa Pham on 24.02.2018. 6 | // Copyright © 2018 Khoa Pham. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension UIViewController { 12 | /// Add child view controller and its view 13 | func add(childViewController: UIViewController) { 14 | addChildViewController(childViewController) 15 | view.addSubview(childViewController.view) 16 | childViewController.didMove(toParentViewController: self) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Source/Library/Flow/AppFlowController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppFlowController.swift 3 | // Recipes 4 | // 5 | // Created by Khoa Pham on 24.02.2018. 6 | // Copyright © 2018 Khoa Pham. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | /// Manage app flow 12 | final class AppFlowController: UIViewController { 13 | 14 | /// Start the flow 15 | func start() { 16 | let flowController = RecipeFlowController() 17 | add(childViewController: flowController) 18 | flowController.start() 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Source/Library/Flow/RecipeFlowController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RecipeFlowController.swift 3 | // Recipes 4 | // 5 | // Created by Khoa Pham on 24.02.2018. 6 | // Copyright © 2018 Khoa Pham. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import SafariServices 11 | 12 | /// Manage list and detail screens for recipe 13 | final class RecipeFlowController: UINavigationController { 14 | /// Start the flow 15 | func start() { 16 | let service = RecipesService(networking: NetworkService()) 17 | let controller = HomeViewController(recipesService: service) 18 | viewControllers = [controller] 19 | controller.select = { [weak self] recipe in 20 | self?.startDetail(recipe: recipe) 21 | } 22 | } 23 | 24 | private func startDetail(recipe: Recipe) { 25 | let service = RecipesService(networking: NetworkService()) 26 | let controller = RecipeDetailViewController(recipe: recipe, recipesService: service) 27 | 28 | controller.selectInstruction = { [weak self] url in 29 | self?.startWeb(url: url) 30 | } 31 | 32 | controller.selectOriginal = { [weak self] url in 33 | self?.startWeb(url: url) 34 | } 35 | 36 | pushViewController(controller, animated: true) 37 | } 38 | 39 | private func startWeb(url: URL) { 40 | let controller = SFSafariViewController(url: url) 41 | pushViewController(controller, animated: true) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Source/Library/Model/Recipe.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Represent a recipe from API response 4 | struct Recipe: Codable { 5 | /// Name of the Publisher 6 | let publisher: String 7 | 8 | /// Url of the recipe on Food2Fork.com 9 | let url: URL 10 | 11 | /// URL of the image 12 | let sourceUrl: URL 13 | 14 | /// id of recipe 15 | let id: String 16 | 17 | /// Title of the recipe 18 | let title: String 19 | 20 | /// URL of the image 21 | let imageUrl: URL 22 | 23 | /// The Social Ranking of the Recipe 24 | let socialRank: Double 25 | 26 | /// Base url of the publisher 27 | let publisherUrl: URL 28 | 29 | let ingredients: [String]? 30 | 31 | enum CodingKeys: String, CodingKey { 32 | case publisher 33 | case url = "f2f_url" 34 | case sourceUrl = "source_url" 35 | case id = "recipe_id" 36 | case title 37 | case imageUrl = "image_url" 38 | case socialRank = "social_rank" 39 | case publisherUrl = "publisher_url" 40 | case ingredients 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Source/Library/Networking/Networking.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Networking.swift 3 | // Recipes 4 | // 5 | // Created by Khoa Pham on 25.02.2018. 6 | // Copyright © 2018 Khoa Pham. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | protocol Networking { 12 | 13 | /// Fetch data from url and parameters query 14 | /// 15 | /// - Parameters: 16 | /// - url: The url 17 | /// - parameters: Parameters as query items 18 | /// - completion: Called when operation finishes 19 | /// - Returns: The data task 20 | @discardableResult func fetch(resource: Resource, completion: @escaping (Data?) -> Void) -> URLSessionTask? 21 | } 22 | -------------------------------------------------------------------------------- /Source/Library/Networking/Resource.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Resource.swift 3 | // Recipes 4 | // 5 | // Created by Khoa Pham on 25.02.2018. 6 | // Copyright © 2018 Khoa Pham. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | // A network resource, identified by url and parameters 12 | struct Resource { 13 | let url: URL 14 | let path: String? 15 | let httpMethod: String 16 | let parameters: [String: String] 17 | 18 | init(url: URL, path: String? = nil, httpMethod: String = "GET", parameters: [String: String] = [:]) { 19 | self.url = url 20 | self.path = path 21 | self.httpMethod = httpMethod 22 | self.parameters = parameters 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Source/Library/Service/CacheService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageService.swift 3 | // Recipes 4 | // 5 | // Created by Khoa Pham on 24.02.2018. 6 | // Copyright © 2018 Khoa Pham. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftHash 11 | 12 | /// Save and load data to memory and disk cache 13 | final class CacheService { 14 | 15 | /// For get or load data in memory 16 | private let memory = NSCache() 17 | 18 | /// The path url that contain cached files (mp3 files and image files) 19 | private let diskPath: URL 20 | 21 | /// For checking file or directory exists in a specified path 22 | private let fileManager: FileManager 23 | 24 | /// Make sure all operation are executed serially 25 | private let serialQueue = DispatchQueue(label: "Recipes") 26 | 27 | init(fileManager: FileManager = FileManager.default) { 28 | self.fileManager = fileManager 29 | do { 30 | let documentDirectory = try fileManager.url( 31 | for: .documentDirectory, 32 | in: .userDomainMask, 33 | appropriateFor: nil, 34 | create: true 35 | ) 36 | diskPath = documentDirectory.appendingPathComponent("Recipes") 37 | try createDirectoryIfNeeded() 38 | } catch { 39 | fatalError() 40 | } 41 | } 42 | 43 | /// Save data 44 | /// 45 | /// - Parameters: 46 | /// - data: The data to save 47 | /// - key: Key to identify cached item 48 | func save(data: Data, key: String, completion: (() -> Void)? = nil) { 49 | let key = MD5(key) 50 | 51 | serialQueue.async { 52 | self.memory.setObject(data as NSData, forKey: key as NSString) 53 | do { 54 | try data.write(to: self.filePath(key: key)) 55 | completion?() 56 | } catch { 57 | print(error) 58 | } 59 | } 60 | } 61 | 62 | /// Load data specified by key 63 | /// 64 | /// - Parameters: 65 | /// - key: Key to identify cached item 66 | /// - completion: Called when operation finishes 67 | func load(key: String, completion: @escaping (Data?) -> Void) { 68 | let key = MD5(key) 69 | 70 | serialQueue.async { 71 | // If object is in memory 72 | if let data = self.memory.object(forKey: key as NSString) { 73 | completion(data as Data) 74 | return 75 | } 76 | 77 | // If object is in disk 78 | if let data = try? Data(contentsOf: self.filePath(key: key)) { 79 | // Set back to memory 80 | self.memory.setObject(data as NSData, forKey: key as NSString) 81 | completion(data) 82 | return 83 | } 84 | 85 | completion(nil) 86 | } 87 | } 88 | 89 | /// Convenient method to access file named key in diskPath directory 90 | /// 91 | /// - Parameter key: The key, this is the file name 92 | /// - Returns: The file url 93 | private func filePath(key: String) -> URL { 94 | return diskPath.appendingPathComponent(key) 95 | } 96 | 97 | private func createDirectoryIfNeeded() throws { 98 | if !fileManager.fileExists(atPath: diskPath.path) { 99 | try fileManager.createDirectory(at: diskPath, withIntermediateDirectories: false, attributes: nil) 100 | } 101 | } 102 | 103 | /// Clear all items in memory and disk cache 104 | func clear() throws { 105 | memory.removeAllObjects() 106 | try fileManager.removeItem(at: diskPath) 107 | try createDirectoryIfNeeded() 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /Source/Library/Service/ImageService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageService.swift 3 | // Recipes 4 | // 5 | // Created by Khoa Pham on 24.02.2018. 6 | // Copyright © 2018 Khoa Pham. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | /// Check local cache and fetch remote image 12 | final class ImageService { 13 | 14 | private let networkService: Networking 15 | private let cacheService: CacheService 16 | private var task: URLSessionTask? 17 | 18 | init(networkService: Networking, cacheService: CacheService) { 19 | self.networkService = networkService 20 | self.cacheService = cacheService 21 | } 22 | 23 | /// Fetch image from url 24 | /// 25 | /// - Parameters: 26 | /// - url: The remote url for image 27 | /// - completion: Called when operation finishes 28 | func fetch(url: URL, completion: @escaping (UIImage?) -> Void) { 29 | // Cancel existing task if any 30 | task?.cancel() 31 | 32 | // Try load from cache 33 | cacheService.load(key: url.absoluteString, completion: { [weak self] cachedData in 34 | if let data = cachedData, let image = UIImage(data: data) { 35 | DispatchQueue.main.async { 36 | completion(image) 37 | } 38 | } else { 39 | // Try to request from network 40 | let resource = Resource(url: url) 41 | self?.task = self?.networkService.fetch(resource: resource, completion: { networkData in 42 | if let data = networkData, let image = UIImage(data: data) { 43 | // Save to cache 44 | self?.cacheService.save(data: data, key: url.absoluteString) 45 | DispatchQueue.main.async { 46 | completion(image) 47 | } 48 | } else { 49 | print("Error loading image at \(url)") 50 | } 51 | }) 52 | 53 | self?.task?.resume() 54 | } 55 | }) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Source/Library/Service/NetworkService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NetworkService.swift 3 | // Recipes 4 | // 5 | // Created by Khoa Pham on 24.02.2018. 6 | // Copyright © 2018 Khoa Pham. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// Used to fetch data from network 12 | final class NetworkService: Networking { 13 | private let session: URLSession 14 | 15 | init(configuration: URLSessionConfiguration = URLSessionConfiguration.default) { 16 | self.session = URLSession(configuration: configuration) 17 | } 18 | 19 | @discardableResult func fetch(resource: Resource, completion: @escaping (Data?) -> Void) -> URLSessionTask? { 20 | guard let request = makeRequest(resource: resource) else { 21 | completion(nil) 22 | return nil 23 | } 24 | 25 | let task = session.dataTask(with: request, completionHandler: { data, _, error in 26 | guard let data = data, error == nil else { 27 | completion(nil) 28 | return 29 | } 30 | 31 | completion(data) 32 | }) 33 | 34 | task.resume() 35 | return task 36 | } 37 | 38 | /// Convenient method to make request 39 | /// 40 | /// - Parameters: 41 | /// - resource: Network resource 42 | /// - Returns: Constructed URL request 43 | private func makeRequest(resource: Resource) -> URLRequest? { 44 | let url = resource.path.map({ resource.url.appendingPathComponent($0) }) ?? resource.url 45 | guard var component = URLComponents(url: url, resolvingAgainstBaseURL: true) else { 46 | assertionFailure() 47 | return nil 48 | } 49 | 50 | component.queryItems = resource.parameters.map({ 51 | return URLQueryItem(name: $0, value: $1) 52 | }) 53 | 54 | guard let resolvedUrl = component.url else { 55 | assertionFailure() 56 | return nil 57 | } 58 | 59 | var request = URLRequest(url: resolvedUrl) 60 | request.httpMethod = resource.httpMethod 61 | return request 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Source/Library/Service/RecipesService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RecipesService.swift 3 | // Recipes 4 | // 5 | // Created by Khoa Pham on 24.02.2018. 6 | // Copyright © 2018 Khoa Pham. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | final class RecipesService { 12 | private let baseUrl = URL(string: "https://food2fork.com/api")! 13 | private let networking: Networking 14 | 15 | init(networking: Networking) { 16 | self.networking = networking 17 | } 18 | 19 | /// Fetch recipes with highest rating 20 | /// 21 | /// - Parameter completion: Called when operation finishes 22 | func fetchTopRating(completion: @escaping ([Recipe]) -> Void) { 23 | let resource = Resource(url: baseUrl, path: "search", parameters: [ 24 | "key": AppConfig.apiKey 25 | ]) 26 | 27 | _ = networking.fetch(resource: resource, completion: { data in 28 | DispatchQueue.main.async { 29 | completion(data.flatMap({ RecipeListResponse.make(data: $0)?.recipes }) ?? []) 30 | } 31 | }) 32 | } 33 | 34 | /// Fetch single entity based on recipe id 35 | /// 36 | /// - Parameters: 37 | /// - recipeId: The recipe id 38 | /// - completion: Called when operation finishes 39 | func fetch(recipeId: String, completion: @escaping (Recipe?) -> Void) { 40 | let resource = Resource(url: baseUrl, path: "get", parameters: [ 41 | "key": AppConfig.apiKey, 42 | "rId": recipeId 43 | ]) 44 | 45 | _ = networking.fetch(resource: resource, completion: { data in 46 | DispatchQueue.main.async { 47 | completion(data.flatMap({ RecipeResponse.make(data: $0)?.recipe })) 48 | } 49 | }) 50 | } 51 | 52 | /// Search recipes based on query 53 | /// 54 | /// - Parameters: 55 | /// - query: The search query 56 | /// - completion: Called when operation finishes 57 | /// - Returns: The network task 58 | @discardableResult func search(query: String, completion: @escaping ([Recipe]) -> Void) -> URLSessionTask? { 59 | let resource = Resource(url: baseUrl, path: "search", parameters: [ 60 | "key": AppConfig.apiKey, 61 | "q": query 62 | ]) 63 | 64 | return networking.fetch(resource: resource, completion: { data in 65 | DispatchQueue.main.async { 66 | completion(data.flatMap({ RecipeListResponse.make(data: $0)?.recipes }) ?? []) 67 | } 68 | }) 69 | } 70 | } 71 | 72 | private class RecipeListResponse: Decodable { 73 | let count: Int 74 | let recipes: [Recipe] 75 | 76 | static func make(data: Data) -> RecipeListResponse? { 77 | return try? JSONDecoder().decode(RecipeListResponse.self, from: data) 78 | } 79 | } 80 | 81 | private class RecipeResponse: Decodable { 82 | let recipe: Recipe 83 | 84 | static func make(data: Data) -> RecipeResponse? { 85 | return try? JSONDecoder().decode(RecipeResponse.self, from: data) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /Source/Library/Utils/Debouncer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThrottleHandler.swift 3 | // Recipes 4 | // 5 | // Created by Khoa Pham on 25.02.2018. 6 | // Copyright © 2018 Khoa Pham. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// Throttle action, allow action to be performed after some delay 12 | final class Debouncer { 13 | private let delay: TimeInterval 14 | private var workItem: DispatchWorkItem? 15 | 16 | init(delay: TimeInterval) { 17 | self.delay = delay 18 | } 19 | 20 | /// Trigger the action after some delay 21 | func schedule(action: @escaping () -> Void) { 22 | workItem?.cancel() 23 | workItem = DispatchWorkItem(block: action) 24 | DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem!) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Source/Library/View/EmptyView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EmptyView.swift 3 | // Recipes 4 | // 5 | // Created by Khoa Pham on 25.02.2018. 6 | // Copyright © 2018 Khoa Pham. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | /// Used to show when there's no data 12 | final class EmptyView: UIView { 13 | private lazy var imageView: UIImageView = self.makeImageView() 14 | private lazy var label: UILabel = self.makeLabel() 15 | 16 | required init(text: String) { 17 | super.init(frame: .zero) 18 | 19 | isUserInteractionEnabled = false 20 | addSubviews([imageView, label]) 21 | label.text = text 22 | setupConstraints() 23 | } 24 | 25 | required init?(coder aDecoder: NSCoder) { 26 | fatalError() 27 | } 28 | 29 | private func setupConstraints() { 30 | NSLayoutConstraint.activate([ 31 | imageView.centerXAnchor.constraint(equalTo: centerXAnchor), 32 | imageView.centerYAnchor.constraint(equalTo: centerYAnchor, constant: -50), 33 | imageView.widthAnchor.constraint(equalTo: widthAnchor, multiplier: 0.4), 34 | imageView.heightAnchor.constraint(equalTo: imageView.widthAnchor, multiplier: 1), 35 | 36 | label.centerXAnchor.constraint(equalTo: centerXAnchor), 37 | label.topAnchor.constraint(equalTo: imageView.bottomAnchor, constant: 10) 38 | ]) 39 | } 40 | 41 | // MARK: - Make 42 | 43 | private func makeImageView() -> UIImageView { 44 | let imageView = UIImageView() 45 | imageView.contentMode = .scaleAspectFill 46 | imageView.clipsToBounds = true 47 | imageView.image = R.image.notFound() 48 | return imageView 49 | } 50 | 51 | private func makeLabel() -> UILabel { 52 | let label = UILabel() 53 | label.textAlignment = .center 54 | label.font = UIFont.preferredFont(forTextStyle: .headline) 55 | return label 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Source/Library/View/ScrollableView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScrollableView.swift 3 | // Recipes 4 | // 5 | // Created by Khoa Pham on 25.02.2018. 6 | // Copyright © 2018 Khoa Pham. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | /// Vertically layout view using Auto Layout in UIScrollView 12 | final class ScrollableView: UIView { 13 | private let scrollView = UIScrollView() 14 | private let contentView = UIView() 15 | 16 | struct Pair { 17 | let view: UIView 18 | let inset: UIEdgeInsets 19 | } 20 | 21 | override init(frame: CGRect) { 22 | super.init(frame: frame) 23 | 24 | scrollView.showsHorizontalScrollIndicator = false 25 | scrollView.alwaysBounceHorizontal = false 26 | addSubview(scrollView) 27 | 28 | scrollView.addSubview(contentView) 29 | 30 | NSLayoutConstraint.activate([ 31 | scrollView.topAnchor.constraint(equalTo: topAnchor), 32 | scrollView.bottomAnchor.constraint(equalTo: bottomAnchor), 33 | scrollView.leftAnchor.constraint(equalTo: leftAnchor), 34 | scrollView.rightAnchor.constraint(equalTo: rightAnchor), 35 | 36 | contentView.topAnchor.constraint(equalTo: scrollView.topAnchor), 37 | contentView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor), 38 | contentView.leftAnchor.constraint(equalTo: leftAnchor), 39 | contentView.rightAnchor.constraint(equalTo: rightAnchor) 40 | ]) 41 | } 42 | 43 | required init?(coder aDecoder: NSCoder) { 44 | fatalError() 45 | } 46 | 47 | func setup(pairs: [Pair]) { 48 | pairs.enumerated().forEach({ tuple in 49 | let view = tuple.element.view 50 | let inset = tuple.element.inset 51 | let offset = tuple.offset 52 | 53 | scrollView.addSubview(view) 54 | 55 | if offset == 0 { 56 | NSLayoutConstraint.activate([ 57 | view.topAnchor.constraint(equalTo: contentView.topAnchor, constant: inset.top) 58 | ]) 59 | } else { 60 | NSLayoutConstraint.activate([ 61 | view.topAnchor.constraint(equalTo: pairs[offset - 1].view.bottomAnchor, constant: inset.top) 62 | ]) 63 | } 64 | 65 | if offset == pairs.count - 1 { 66 | NSLayoutConstraint.activate([ 67 | view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -inset.bottom) 68 | ]) 69 | } 70 | 71 | NSLayoutConstraint.activate([ 72 | view.leftAnchor.constraint(equalTo: contentView.leftAnchor, constant: inset.left), 73 | view.rightAnchor.constraint(equalTo: contentView.rightAnchor, constant: -inset.right) 74 | ]) 75 | }) 76 | } 77 | } 78 | --------------------------------------------------------------------------------