├── .gitignore ├── Recipes ├── Assets.xcassets │ ├── Contents.json │ └── AppIcon.appiconset │ │ └── Contents.json ├── Core │ ├── Models │ │ ├── Ingredient.swift │ │ └── Recipe.swift │ ├── Configuration.swift │ ├── Commands │ │ ├── RecipesCommand.swift │ │ └── DownloadImage.swift │ ├── Common │ │ ├── Dispatcher.swift │ │ └── Utils.swift │ ├── ServiceError.swift │ ├── Session.swift │ ├── Service.swift │ ├── CacheFacade.swift │ ├── ImageFacade.swift │ └── ServiceFacade.swift ├── ProductPage │ ├── ProductPageProtocol.swift │ ├── ProductPagePresenter.swift │ ├── ProductPageRouter.swift │ └── ProductPageViewController.swift ├── Product List │ ├── Interactors │ │ ├── FilterDataInteractor.swift │ │ └── FetchDataInteractor.swift │ ├── ProductListCell.swift │ ├── ProductListProtocols.swift │ ├── ProductListRouter.swift │ ├── ProductListPresenter.swift │ └── ProductListViewController.swift ├── AppDelegate.swift ├── Constants.swift ├── Base.lproj │ ├── LaunchScreen.storyboard │ └── Main.storyboard └── Info.plist ├── RecipesApp.xcodeproj ├── project.xcworkspace │ ├── xcuserdata │ │ └── mattiacantalu.xcuserdatad │ │ │ ├── UserInterfaceState.xcuserstate │ │ │ └── WorkspaceSettings.xcsettings │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── WorkspaceSettings.xcsettings │ │ └── IDEWorkspaceChecks.plist ├── xcuserdata │ └── mattiacantalu.xcuserdatad │ │ ├── xcschemes │ │ └── xcschememanagement.plist │ │ └── xcdebugger │ │ └── Breakpoints_v2.xcbkptlist ├── xcshareddata │ └── xcschemes │ │ ├── RecipesAppTests.xcscheme │ │ └── RecipesApp.xcscheme └── project.pbxproj ├── RecipesTests ├── Mocks │ ├── MockedFetchDataInteractor.swift │ ├── MockedFilterDataInteractor.swift │ ├── MockedProductPageView.swift │ ├── Recipe.json │ ├── MockedProductListView.swift │ ├── MockedSession.swift │ ├── MockedProductListPresenter.swift │ └── Recipes.json ├── ProductPagePresenterTests.swift ├── UtilsTests.swift ├── Info.plist ├── JSONUtil.swift ├── CacheFacadeTests.swift ├── FetchDataInteractorTests.swift ├── RecipesCommandTests.swift ├── FilterDataInteractorTests.swift ├── RecipesResponseTests.swift └── ProductListPresenterTests.swift ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | *xcuserdatad 2 | -------------------------------------------------------------------------------- /Recipes/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /Recipes/Core/Models/Ingredient.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct Ingredient: Codable { 4 | let quantity: String 5 | let name: String 6 | let type: String 7 | } 8 | -------------------------------------------------------------------------------- /RecipesApp.xcodeproj/project.xcworkspace/xcuserdata/mattiacantalu.xcuserdatad/UserInterfaceState.xcuserstate: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattiacantalu/Recipes/HEAD/RecipesApp.xcodeproj/project.xcworkspace/xcuserdata/mattiacantalu.xcuserdatad/UserInterfaceState.xcuserstate -------------------------------------------------------------------------------- /RecipesApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /RecipesApp.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /Recipes/Core/Models/Recipe.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct Recipe: Codable { 4 | let name: String 5 | let ingredients: [Ingredient] 6 | let steps: [String] 7 | let timers: [Int] 8 | let imageURL: String 9 | let originalURL: String? 10 | } 11 | -------------------------------------------------------------------------------- /Recipes/Core/Configuration.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct Configuration { 4 | let baseUrl: String 5 | let service: Service 6 | 7 | init(baseUrl: String, 8 | service: Service = Service()) { 9 | self.baseUrl = baseUrl 10 | self.service = service 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /RecipesApp.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Recipes/ProductPage/ProductPageProtocol.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol RecipeProtocol { 4 | func fetch() -> Recipe? 5 | } 6 | 7 | protocol ProductPagePresenterProtocol { 8 | func performFetch() 9 | } 10 | 11 | protocol ProductPageViewProtocol: class { 12 | func load(recipe: Recipe?) 13 | func loadCallbacks() 14 | } 15 | -------------------------------------------------------------------------------- /Recipes/ProductPage/ProductPagePresenter.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | class ProductPagePresenter { 4 | weak var view: ProductPageViewProtocol? 5 | var recipe: RecipeProtocol? 6 | } 7 | 8 | extension ProductPagePresenter: ProductPagePresenterProtocol { 9 | func performFetch() { 10 | view?.loadCallbacks() 11 | view?.load(recipe: recipe?.fetch()) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /RecipesTests/Mocks/MockedFetchDataInteractor.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | @testable import RecipesApp 3 | 4 | class MockedFetchDataInteractor: FetchDataInteractorProtocol { 5 | var counterPerform: Int = 0 6 | 7 | var performHandler: (() -> Void)? 8 | 9 | func perform() { 10 | counterPerform += 1 11 | if let performHandler = performHandler { 12 | return performHandler() 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Recipes/Core/Commands/RecipesCommand.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension ServiceFacade: FacadeProtocol { 4 | func getRecipes(completion: @escaping ((Result<[Recipe]>) -> Void)) { 5 | let url = baseURL? 6 | .appendingPathComponent("sample") 7 | .appendingPathComponent("recipes.json") 8 | 9 | performTry({ try self.makeRequest(url, 10 | map: [Recipe].self, 11 | completion: completion) }) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Recipes/Core/Common/Dispatcher.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol Dispatcher { 4 | func dispatch(callback: @escaping () -> Void) 5 | } 6 | 7 | struct DefaultDispatcher: Dispatcher { 8 | init() {} 9 | 10 | func dispatch(callback: @escaping () -> Void) { 11 | DispatchQueue.main.async { 12 | callback() 13 | } 14 | } 15 | } 16 | 17 | struct SyncDispatcher: Dispatcher { 18 | init() {} 19 | 20 | func dispatch(callback: @escaping () -> Void) { 21 | callback() 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Recipes/Core/ServiceError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum ServiceError: Error { 4 | case responseError 5 | case noData 6 | case wrongUrl 7 | } 8 | 9 | extension ServiceError: LocalizedError { 10 | public var errorDescription: String? { 11 | switch self { 12 | case .responseError: 13 | return Constants.Error.responseError 14 | case .noData: 15 | return Constants.Error.noData 16 | case .wrongUrl: 17 | return Constants.Error.wrongUrl 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /RecipesTests/Mocks/MockedFilterDataInteractor.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | @testable import RecipesApp 3 | 4 | class MockedFilterDataInteractor: FilterDataInteractorProtocol { 5 | var counterPerform: Int = 0 6 | 7 | var performHandler: (((Recipe) -> Bool, [Recipe]?) -> Void)? 8 | 9 | func perform(filter: (Recipe) -> Bool, 10 | on recipes: [Recipe]?) { 11 | counterPerform += 1 12 | if let performHandler = performHandler { 13 | return performHandler(filter, recipes) 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Recipes/Product List/Interactors/FilterDataInteractor.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | class FilterDataInteractor { 4 | private let presenter: ProductListPresenterProtocol 5 | 6 | init(presenter: ProductListPresenterProtocol) { 7 | self.presenter = presenter 8 | } 9 | } 10 | 11 | extension FilterDataInteractor: FilterDataInteractorProtocol { 12 | func perform(filter: (Recipe) -> Bool, 13 | on recipes: [Recipe]?) { 14 | let filtered = recipes?.filter({ filter($0) }) ?? [] 15 | presenter.on(filtered: filtered) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /RecipesTests/ProductPagePresenterTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import RecipesApp 3 | 4 | class ProductPagePresenterTests: XCTestCase { 5 | var view: MockedProductPageView? 6 | var sut: ProductPagePresenter? 7 | 8 | override func setUp() { 9 | sut = ProductPagePresenter() 10 | view = MockedProductPageView() 11 | 12 | sut?.view = view 13 | } 14 | 15 | func testFetchRecipeShouldSuccess() { 16 | sut?.performFetch() 17 | XCTAssertEqual(view?.counterLoadCallbacks, 1) 18 | XCTAssertEqual(view?.counterRecipe, 1) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Recipes/Core/Commands/DownloadImage.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | extension ImageFacade: ImageProtocol { 5 | func downloadImage(from link: String?, 6 | completion: @escaping (_ image: UIImage?) -> Void) { 7 | 8 | guard let imageUrl = link?.url else { 9 | self.dispatch { 10 | completion(nil) 11 | } 12 | return 13 | } 14 | 15 | performTask(with: imageUrl, 16 | completion: completion) 17 | } 18 | } 19 | 20 | extension String { 21 | var url: URL? { 22 | return [self].compactMap({ URL(string: $0) }).first ?? nil 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /RecipesApp.xcodeproj/project.xcworkspace/xcuserdata/mattiacantalu.xcuserdatad/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BuildLocationStyle 6 | UseAppPreferences 7 | CustomBuildLocationType 8 | RelativeToDerivedData 9 | DerivedDataLocationStyle 10 | Default 11 | EnabledFullIndexStoreVisibility 12 | 13 | IssueFilterStyle 14 | ShowActiveSchemeOnly 15 | LiveSourceIssuesEnabled 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /Recipes/ProductPage/ProductPageRouter.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | class ProductPageRouter { 5 | let view: ProductPageViewController? 6 | 7 | init(recipe: RecipeProtocol?) { 8 | view = UIStoryboard(name: Constants.Storyboard.name, bundle: nil) 9 | .instantiateViewController(withIdentifier: "productPageViewController") as? ProductPageViewController 10 | 11 | let imageService = ImageService(cache: CacheFacade()) 12 | 13 | let presenter = ProductPagePresenter() 14 | view?.presenter = presenter 15 | view?.imageService = ImageFacade(configuration: imageService) 16 | presenter.view = view 17 | presenter.recipe = recipe 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /RecipesTests/UtilsTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import RecipesApp 3 | 4 | class UtilsTests: XCTestCase { 5 | 6 | func testUrlStringShouldReturnValidUrl() { 7 | let urlString = "http://sample.com" 8 | guard let url = URL(string: urlString) else { 9 | XCTFail("Url error!") 10 | return 11 | } 12 | let validUrl = urlString.url 13 | XCTAssertNotNil(validUrl) 14 | XCTAssertEqual(validUrl, url) 15 | } 16 | 17 | func testEmptyUrlStringShouldReturnNil() { 18 | XCTAssertNil("".url) 19 | } 20 | 21 | func testNilUrlStringShouldReturnNil() { 22 | let urlString: String? = nil 23 | XCTAssertNil(urlString?.url) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Recipes/Core/Session.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol SessionProtocol { 4 | func dataTask(with request: URLRequest, 5 | completion: @escaping (Data?, URLResponse?, Error?) -> Void) 6 | } 7 | 8 | struct Session: SessionProtocol { 9 | private let session: URLSession 10 | 11 | init(urlSession: URLSession = URLSession.shared) { 12 | self.session = urlSession 13 | } 14 | 15 | func dataTask(with request: URLRequest, completion: @escaping (Data?, URLResponse?, Error?) -> Void) { 16 | 17 | let task = session.dataTask(with: request) { (responseData, urlResponse, responseError) in 18 | completion(responseData, urlResponse, responseError) 19 | } 20 | 21 | task.resume() 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /RecipesTests/Mocks/MockedProductPageView.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | @testable import RecipesApp 3 | 4 | class MockedProductPageView: ProductPageViewProtocol { 5 | var counterRecipe: Int = 0 6 | var counterLoadCallbacks: Int = 0 7 | 8 | var loadRecipeHandler: ((Recipe?) -> Void)? 9 | var loadCallbacksHandler: (() -> Void)? 10 | 11 | 12 | func load(recipe: Recipe?) { 13 | counterRecipe += 1 14 | if let loadRecipeHandler = loadRecipeHandler { 15 | return loadRecipeHandler(recipe) 16 | } 17 | } 18 | 19 | func loadCallbacks() { 20 | counterLoadCallbacks += 1 21 | if let loadCallbacksHandler = loadCallbacksHandler { 22 | return loadCallbacksHandler() 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /Recipes/Product List/Interactors/FetchDataInteractor.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | class FetchDataInteractor { 4 | private let service: FacadeProtocol 5 | private let presenter: ProductListPresenterProtocol 6 | 7 | init(service: FacadeProtocol, 8 | presenter: ProductListPresenterProtocol) { 9 | self.service = service 10 | self.presenter = presenter 11 | } 12 | } 13 | 14 | extension FetchDataInteractor: FetchDataInteractorProtocol { 15 | func perform() { 16 | service.getRecipes(completion: { result in 17 | switch result { 18 | case .success(let response): 19 | self.presenter.on(recipes: response) 20 | case .failure(let error): 21 | self.presenter.on(error: error) 22 | } 23 | }) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /RecipesTests/JSONUtil.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | final class JSONUtil { 4 | 5 | static func loadClass(fromResource resource: String, ofType classType: T.Type) throws -> T? { 6 | guard let data = JSONUtil.loadData(fromResource: resource) else { 7 | return nil 8 | } 9 | do { 10 | return try JSONDecoder().decode(classType, from: data) 11 | } catch { 12 | return nil 13 | } 14 | } 15 | 16 | static func loadData(fromResource resource: String, ofType type: String = "json") -> Data? { 17 | guard let path = Bundle(for: JSONUtil.self).path(forResource: resource, ofType: type) else { 18 | return nil 19 | } 20 | do { 21 | return try Data(contentsOf: URL(fileURLWithPath: path)) 22 | } catch { 23 | return nil 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /RecipesApp.xcodeproj/xcuserdata/mattiacantalu.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | RecipesApp.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 0 11 | 12 | RecipesAppTests.xcscheme_^#shared#^_ 13 | 14 | orderHint 15 | 1 16 | 17 | 18 | SuppressBuildableAutocreation 19 | 20 | 62748BCA2175084B000B403B 21 | 22 | primary 23 | 24 | 25 | 62748BDE2175084C000B403B 26 | 27 | primary 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /Recipes/Product List/ProductListCell.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | class ProductListCell: UICollectionViewCell { 4 | 5 | @IBOutlet private weak var imageView: UIImageView? 6 | @IBOutlet private weak var titleLabel: UILabel? 7 | @IBOutlet private weak var ingredientsLabel: UILabel? 8 | @IBOutlet private weak var minutesLabel: UILabel? 9 | 10 | var imageService: ImageProtocol? 11 | 12 | var recipe: Recipe? { 13 | didSet { 14 | load(recipe) 15 | } 16 | } 17 | 18 | func load(_ rc: Recipe?) { 19 | imageService?.downloadImage(from: rc?.imageURL) { image in 20 | self.imageView?.image = image 21 | } 22 | titleLabel?.text = rc?.name 23 | ingredientsLabel?.text = "\(rc?.ingredients.count ?? 0) \(Constants.Title.ingredients)" 24 | minutesLabel?.text = "\(rc?.timers.reduce(0, +) ?? 0) \(Constants.Title.minutes)" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Recipes/Product List/ProductListProtocols.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | protocol ProductListPresenterProtocol { 5 | func fetchRecipes() 6 | func select(recipe: Recipe?) 7 | func filter(by text: String) 8 | func filter(by time: (min: Int, max: Int)) 9 | func filter(by ingredients: (Int, Int), steps: (Int, Int), timer: (Int, Int)) 10 | func reset() 11 | 12 | func on(recipes: [Recipe]) 13 | func on(filtered: [Recipe]) 14 | func on(error: Error) 15 | 16 | func by(text: String) -> (Recipe) -> Bool 17 | func by(range: (Int, Int)) -> (Recipe) -> Bool 18 | func by(ingredients: (Int, Int), steps: (Int, Int), timer: (Int, Int)) -> (Recipe) -> Bool 19 | } 20 | 21 | protocol ProductListViewProtocol: class { 22 | func show(recipes: [Recipe]) 23 | func show(filtered: [Recipe]) 24 | func show(error: Error) 25 | } 26 | 27 | protocol FetchDataInteractorProtocol { 28 | func perform() 29 | } 30 | 31 | protocol FilterDataInteractorProtocol { 32 | func perform(filter: (Recipe) -> Bool, on recipes: [Recipe]?) 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Mattia Cantalù 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /RecipesTests/CacheFacadeTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import RecipesApp 3 | 4 | class CacheFacadeTests: XCTestCase { 5 | 6 | func testSetObjectForKeyTriggeringExpirationShoudlReturnNil() { 7 | let cache = CacheFacade(expiration: 0) 8 | let item = CacheableItem(value: Data()) 9 | cache.set(obj: item, for: "key") 10 | XCTAssertNil(cache.object(for: "key")) 11 | } 12 | 13 | func testSetObjectForKeyNoTriggeringExpirationShoudlReturnValue() { 14 | let cache = CacheFacade(expiration: 10) 15 | let item = CacheableItem(value: Data()) 16 | cache.set(obj: item, for: "key") 17 | XCTAssertNotNil(cache.object(for: "key")) 18 | } 19 | 20 | func testGetObjectForNotSetKeyShoudlReturnNil() { 21 | let cache = CacheFacade(expiration: 0) 22 | XCTAssertNil(cache.object(for: "key")) 23 | } 24 | 25 | func testValidDateShouldReturnTrue() { 26 | XCTAssertTrue(Date().isValid(TimeInterval(10))) 27 | } 28 | 29 | func testValidDateShouldReturnFalse() { 30 | XCTAssertFalse(Date().isValid(TimeInterval(0))) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Recipes/Core/Common/Utils.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | func performTry(_ function: @escaping (() throws -> Void)) { 5 | do { 6 | return try function() 7 | } catch { 8 | print("❌ \(error) ❌") 9 | } 10 | } 11 | 12 | extension UIAlertController { 13 | static func show(message: String, actions: (UIAlertController) -> Void, in controller: UIViewController) { 14 | let alert = UIAlertController(title: nil, 15 | message: message, 16 | preferredStyle: .actionSheet) 17 | actions(alert) 18 | defaults(alert) 19 | 20 | controller.present(alert, animated: true, completion: nil) 21 | } 22 | 23 | private static var defaults: (UIAlertController) -> Void { 24 | let defaultActions: (UIAlertController) -> Void = { alert in 25 | let cancel = UIAlertAction(title: Constants.Title.cancel, 26 | style: UIAlertAction.Style.cancel, 27 | handler: nil) 28 | alert.addAction(cancel) 29 | } 30 | return defaultActions 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Recipes/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | @UIApplicationMain 4 | class AppDelegate: UIResponder, UIApplicationDelegate { 5 | 6 | var window: UIWindow? 7 | 8 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 9 | 10 | try? rootViewController() 11 | 12 | return true 13 | } 14 | 15 | private func rootViewController() throws { 16 | window = UIWindow(frame: UIScreen.main.bounds) 17 | let router = ProductListRouter() 18 | guard let view = router.view else { 19 | throw ASError.controllerNotAvailable 20 | } 21 | window?.rootViewController = UINavigationController(rootViewController: view) 22 | window?.makeKeyAndVisible() 23 | } 24 | 25 | func applicationWillResignActive(_ application: UIApplication) {} 26 | func applicationDidEnterBackground(_ application: UIApplication) {} 27 | func applicationWillEnterForeground(_ application: UIApplication) {} 28 | func applicationDidBecomeActive(_ application: UIApplication) {} 29 | func applicationWillTerminate(_ application: UIApplication) {} 30 | } 31 | 32 | private enum ASError: Error { 33 | case controllerNotAvailable 34 | } 35 | 36 | -------------------------------------------------------------------------------- /RecipesTests/Mocks/Recipe.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Crock Pot Roast", 3 | "ingredients": [ 4 | { 5 | "quantity": "1", 6 | "name": " beef roast", 7 | "type": "Meat" 8 | }, 9 | { 10 | "quantity": "1 package", 11 | "name": "brown gravy mix", 12 | "type": "Baking" 13 | }, 14 | { 15 | "quantity": "1 package", 16 | "name": "dried Italian salad dressing mix", 17 | "type": "Condiments" 18 | }, 19 | { 20 | "quantity": "1 package", 21 | "name": "dry ranch dressing mix", 22 | "type": "Condiments" 23 | }, 24 | { 25 | "quantity": "1/2 cup", 26 | "name": "water", 27 | "type": "Drinks" 28 | } 29 | ], 30 | "steps": [ 31 | "Place beef roast in crock pot.", 32 | "Mix the dried mixes together in a bowl and sprinkle over the roast.", 33 | "Pour the water around the roast.", 34 | "Cook on low for 7-9 hours." 35 | ], 36 | "timers": [ 37 | 0, 38 | 0, 39 | 0, 40 | 420 41 | ], 42 | "imageURL": "http://img.sndimg.com/food/image/upload/w_266/v1/img/recipes/27/20/8/picVfzLZo.jpg", 43 | "originalURL": "http://www.food.com/recipe/to-die-for-crock-pot-roast-27208" 44 | } -------------------------------------------------------------------------------- /RecipesTests/Mocks/MockedProductListView.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | @testable import RecipesApp 3 | 4 | class MockedProductListView: ProductListViewProtocol { 5 | var counterShowRecipes: Int = 0 6 | var counterShowFiltered: Int = 0 7 | var counterShowError: Int = 0 8 | var counterPush: Int = 0 9 | 10 | var showRecipesHandler: (([Recipe]) -> Void)? 11 | var showFilteredHandler: (([Recipe]) -> Void)? 12 | var showErrorHandler: ((Error) -> Void)? 13 | var pushHandler: ((RecipeProtocol) -> Void)? 14 | 15 | 16 | func show(recipes: [Recipe]) { 17 | counterShowRecipes += 1 18 | if let showRecipesHandler = showRecipesHandler { 19 | return showRecipesHandler(recipes) 20 | } 21 | } 22 | 23 | func show(filtered recipes: [Recipe]) { 24 | counterShowFiltered += 1 25 | if let showFilteredHandler = showFilteredHandler { 26 | return showFilteredHandler(recipes) 27 | } 28 | } 29 | 30 | func show(error: Error) { 31 | counterShowError += 1 32 | if let showErrorHandler = showErrorHandler { 33 | return showErrorHandler(error) 34 | } 35 | } 36 | 37 | func push(with delegate: RecipeProtocol) { 38 | counterPush += 1 39 | if let pushHandler = pushHandler { 40 | return pushHandler(delegate) 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /RecipesTests/Mocks/MockedSession.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | @testable import RecipesApp 3 | 4 | struct MockedSession: SessionProtocol { 5 | let data: Data? 6 | let response: URLResponse? 7 | let error: Error? 8 | 9 | let completionRequest: (URLRequest) -> Void 10 | 11 | func dataTask(with request: URLRequest, 12 | completion: @escaping (Data?, URLResponse?, Error?) -> Void) { 13 | 14 | completion(data, response, error) 15 | completionRequest(request) 16 | } 17 | 18 | static func simulate(failure error: Error, 19 | completion: @escaping (URLRequest) -> Void) -> SessionProtocol { 20 | return MockedSession(data: nil, 21 | response: nil, 22 | error: error) { request in 23 | completion(request) 24 | } 25 | } 26 | 27 | static func simulate(success data: Data, 28 | completion: @escaping (URLRequest) -> Void) -> SessionProtocol { 29 | return MockedSession(data: data, 30 | response: nil, 31 | error: nil) { request in 32 | completion(request) 33 | } 34 | } 35 | } 36 | 37 | enum MockedSessionError: Error { 38 | case badURL 39 | case badJSON 40 | case invalidResponse 41 | } 42 | -------------------------------------------------------------------------------- /Recipes/Product List/ProductListRouter.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | class ProductListRouter { 5 | let view: ProductListViewController? 6 | 7 | init() { 8 | 9 | view = UIStoryboard(name: Constants.Storyboard.name, bundle: nil) 10 | .instantiateViewController(withIdentifier: "productListViewController") as? ProductListViewController 11 | 12 | let presenter = ProductListPresenter() 13 | view?.presenter = presenter 14 | view?.imageService = imageService() 15 | presenter.view = view 16 | 17 | let fetchData = FetchDataInteractor(service: serviceFacade(), 18 | presenter: presenter) 19 | presenter.fetchData = fetchData 20 | 21 | let filterData = FilterDataInteractor(presenter: presenter) 22 | presenter.filterData = filterData 23 | } 24 | } 25 | 26 | private func serviceFacade() -> FacadeProtocol { 27 | let service = Service(session: Session(), 28 | cache: CacheFacade()) 29 | let config = Configuration(baseUrl: Constants.URL.baseUrl, 30 | service: service) 31 | return ServiceFacade(configuration: config) 32 | } 33 | 34 | private func imageService() -> ImageProtocol { 35 | let service = ImageService(cache: CacheFacade()) 36 | return ImageFacade(configuration: service) 37 | } 38 | -------------------------------------------------------------------------------- /Recipes/Core/Service.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol ServiceProtocol { 4 | func performTask(with request: URLRequest, 5 | completion: @escaping (Data?, URLResponse?, Error?) -> Void) 6 | func object(for key: String) -> AnyObject? 7 | func set(obj: Any, for key: String) 8 | } 9 | 10 | struct Service { 11 | private let session: SessionProtocol 12 | private let dispatcher: Dispatcher 13 | private let cache: Cacheable 14 | 15 | init(session: SessionProtocol = Session(), 16 | cache: Cacheable = DefaultCache(), 17 | dispatcher: Dispatcher = DefaultDispatcher()) { 18 | self.session = session 19 | self.cache = cache 20 | self.dispatcher = dispatcher 21 | } 22 | } 23 | 24 | extension Service: ServiceProtocol { 25 | func performTask(with request: URLRequest, 26 | completion: @escaping (Data?, URLResponse?, Error?) -> Void) { 27 | session.dataTask(with: request) { (responseData, urlResponse, responseError) in 28 | self.dispatcher.dispatch { 29 | completion(responseData, urlResponse, responseError) 30 | } 31 | } 32 | } 33 | 34 | func object(for key: String) -> AnyObject? { 35 | return cache.object(for: key) 36 | } 37 | 38 | func set(obj: Any, for key: String) { 39 | cache.set(obj: obj, for: key) 40 | } 41 | 42 | func dispatch(completion: @escaping () -> Void) { 43 | dispatcher.dispatch { 44 | completion() 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Recipes/Constants.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct Constants { 4 | struct URL { 5 | static let httpMethod = "GET" 6 | static let baseUrl = "https://mattiacantalu.altervista.org" 7 | } 8 | 9 | struct Error { 10 | static let responseError = "An error has occurred" 11 | static let noData = "No data fetched" 12 | static let wrongUrl = "Unexpected URL creation exception" 13 | } 14 | 15 | struct Time { 16 | static let low = "0-10min" 17 | static let medium = "10-20min" 18 | static let high = "20+min" 19 | } 20 | 21 | struct Difficulty { 22 | static let easy = "Easy" 23 | static let medium = "Medium" 24 | static let hard = "Hard" 25 | } 26 | 27 | struct Title { 28 | static let recipes = "Recipes" 29 | static let difficulty = "Select difficulty" 30 | static let range = "Select time range" 31 | static let reset = "Reset" 32 | static let ingredients = "ingredients" 33 | static let minutes = "minutes" 34 | static let step = "Step" 35 | static let instructions = "Instructions" 36 | static let time = "Time" 37 | static let cancel = "Cancel" 38 | } 39 | 40 | struct Storyboard { 41 | static let name = "Main" 42 | } 43 | 44 | struct Cache { 45 | static let standard = 3600 46 | } 47 | 48 | struct Cell { 49 | static let productListCell = "productListCell" 50 | static let productPageCell = "productPageCell" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Recipes/Core/CacheFacade.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol Cacheable { 4 | func set(obj: Any, for key: String) 5 | func object(for key: String) -> AnyObject? 6 | } 7 | 8 | struct CacheableItem { 9 | let time: Date 10 | let value: Any 11 | 12 | init(value: Any) { 13 | self.value = value 14 | self.time = Date() 15 | } 16 | } 17 | 18 | class CacheFacade: NSCache, Cacheable { 19 | private let expiration: TimeInterval 20 | 21 | init(expiration: Int = Constants.Cache.standard) { 22 | self.expiration = TimeInterval(expiration) 23 | } 24 | 25 | func set(obj: Any, for key: String) { 26 | let item = CacheableItem(value: obj) 27 | self.setObject(item as AnyObject, forKey: key as AnyObject) 28 | } 29 | 30 | func object(for key: String) -> AnyObject? { 31 | guard let item = self.object(forKey: key as AnyObject) as? CacheableItem else { 32 | return nil 33 | } 34 | 35 | guard item.time.isValid(expiration) else { 36 | self.removeObject(forKey: key as AnyObject) 37 | return nil 38 | } 39 | 40 | return item.value as AnyObject 41 | } 42 | } 43 | 44 | class DefaultCache: NSCache, Cacheable { 45 | func set(obj: Any, for key: String) {} 46 | func object(for key: String) -> AnyObject? { return nil } 47 | } 48 | 49 | extension Date 50 | { 51 | func isValid(_ time: TimeInterval) -> Bool { 52 | return self.addingTimeInterval(time) > Date() 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Recipes/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 | -------------------------------------------------------------------------------- /Recipes/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 | UIMainStoryboardFile 26 | Main 27 | UIRequiredDeviceCapabilities 28 | 29 | armv7 30 | 31 | UISupportedInterfaceOrientations 32 | 33 | UIInterfaceOrientationPortrait 34 | UIInterfaceOrientationLandscapeLeft 35 | UIInterfaceOrientationLandscapeRight 36 | 37 | UISupportedInterfaceOrientations~ipad 38 | 39 | UIInterfaceOrientationPortrait 40 | UIInterfaceOrientationPortraitUpsideDown 41 | UIInterfaceOrientationLandscapeLeft 42 | UIInterfaceOrientationLandscapeRight 43 | 44 | NSAppTransportSecurity 45 | 46 | NSAllowsArbitraryLoads 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /Recipes/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "size" : "20x20", 6 | "scale" : "2x" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "size" : "20x20", 11 | "scale" : "3x" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "size" : "29x29", 16 | "scale" : "2x" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "size" : "29x29", 21 | "scale" : "3x" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "size" : "40x40", 26 | "scale" : "2x" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "size" : "40x40", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "size" : "60x60", 36 | "scale" : "2x" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "size" : "60x60", 41 | "scale" : "3x" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "size" : "20x20", 46 | "scale" : "1x" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "size" : "20x20", 51 | "scale" : "2x" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "size" : "29x29", 56 | "scale" : "1x" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "size" : "29x29", 61 | "scale" : "2x" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "size" : "40x40", 66 | "scale" : "1x" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "size" : "40x40", 71 | "scale" : "2x" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "size" : "76x76", 76 | "scale" : "1x" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "size" : "76x76", 81 | "scale" : "2x" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "size" : "83.5x83.5", 86 | "scale" : "2x" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "size" : "1024x1024", 91 | "scale" : "1x" 92 | } 93 | ], 94 | "info" : { 95 | "version" : 1, 96 | "author" : "xcode" 97 | } 98 | } -------------------------------------------------------------------------------- /RecipesApp.xcodeproj/xcshareddata/xcschemes/RecipesAppTests.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 14 | 15 | 17 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 39 | 40 | 41 | 42 | 48 | 49 | 51 | 52 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /Recipes/Core/ImageFacade.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | protocol ImageProtocol { 5 | func downloadImage(from link: String?, 6 | completion: @escaping (_ image: UIImage?) -> Void) 7 | } 8 | 9 | struct ImageService { 10 | let session: URLSession 11 | let cache: Cacheable 12 | let dispatcher: Dispatcher 13 | 14 | init(urlSession: URLSession = URLSession.shared, 15 | cache: Cacheable = DefaultCache(), 16 | dispatcher: Dispatcher = DefaultDispatcher()) { 17 | self.session = urlSession 18 | self.cache = cache 19 | self.dispatcher = dispatcher 20 | } 21 | } 22 | 23 | struct ImageFacade { 24 | private let configuration: ImageService 25 | 26 | init(configuration: ImageService) { 27 | self.configuration = configuration 28 | } 29 | 30 | func performTask(with url: URL, completion: @escaping (_ image: UIImage?) -> Void) { 31 | 32 | if let cachedImage = configuration.cache.object(for: url.absoluteString) as? UIImage { 33 | self.dispatch { 34 | completion(cachedImage) 35 | } 36 | return 37 | } 38 | 39 | configuration.session.dataTask(with: url) { (data, response, error) in 40 | guard 41 | let httpURLResponse = response as? HTTPURLResponse, httpURLResponse.statusCode == 200, 42 | let mimeType = response?.mimeType, mimeType.hasPrefix("image"), 43 | let data = data, error == nil, 44 | let image = UIImage(data: data) else { 45 | self.dispatch { 46 | completion(nil) 47 | } 48 | return 49 | } 50 | 51 | self.dispatch { 52 | self.configuration.cache.set(obj: image, for: url.absoluteString) 53 | completion(image) 54 | } 55 | 56 | }.resume() 57 | } 58 | } 59 | 60 | extension ImageFacade { 61 | func dispatch(completion: @escaping () -> Void) { 62 | configuration.dispatcher.dispatch { 63 | completion() 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /RecipesTests/FetchDataInteractorTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import RecipesApp 3 | 4 | class FetchDataInteractorTests: XCTestCase { 5 | var presenter: MockedProductListPresenter? 6 | var view: MockedProductListView? 7 | var service: ServiceFacade? 8 | var sut: FetchDataInteractor? 9 | 10 | override func setUp() { 11 | presenter = MockedProductListPresenter() 12 | view = MockedProductListView() 13 | } 14 | 15 | func testPerformGetRecipesShouldSuccess() { 16 | guard let data = JSONUtil.loadData(fromResource: "Recipes") else { 17 | XCTFail("JSON data error!") 18 | return 19 | } 20 | let session = MockedSession.simulate(success: data) { request in 21 | XCTAssertEqual(request.url?.absoluteString, "www.sample.com/sample/recipes.json") 22 | } 23 | 24 | create(session: session) 25 | 26 | sut?.perform() 27 | XCTAssertEqual(presenter?.counterOnRecipes, 1) 28 | XCTAssertEqual(presenter?.counterOnError, 0) 29 | } 30 | 31 | func testPerformGetRecipesShouldFail() { 32 | let session = MockedSession.simulate(failure: MockedSessionError.invalidResponse) { request in 33 | XCTAssertEqual(request.url?.absoluteString, "www.sample.com/sample/recipes.json") 34 | } 35 | 36 | create(session: session) 37 | 38 | sut?.perform() 39 | XCTAssertEqual(presenter?.counterOnRecipes, 0) 40 | XCTAssertEqual(presenter?.counterOnError, 1) 41 | } 42 | 43 | private func create(session: SessionProtocol) { 44 | let config = Configuration(baseUrl: "www.sample.com", 45 | service: Service(session: session, 46 | dispatcher: SyncDispatcher())) 47 | service = ServiceFacade(configuration: config) 48 | 49 | guard let presenter = presenter, 50 | let service = service else { 51 | XCTFail("Mocks fail!") 52 | return 53 | } 54 | 55 | sut = FetchDataInteractor(service: service, 56 | presenter: presenter) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /RecipesTests/RecipesCommandTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import RecipesApp 3 | 4 | class ServiceFacadeTests: XCTestCase { 5 | 6 | func testGetRecipesShouldSuccess() { 7 | guard let data = JSONUtil.loadData(fromResource: "Recipes") else { 8 | XCTFail("JSON data error!") 9 | return 10 | } 11 | let session = MockedSession.simulate(success: data) { request in 12 | XCTAssertEqual(request.url?.absoluteString, "www.sample.com/sample/recipes.json") 13 | } 14 | 15 | ServiceFacade(configuration: configurate(session: session)) 16 | .getRecipes(completion: { result in 17 | switch result { 18 | case .success(let response): 19 | XCTAssertNotNil(response) 20 | XCTAssertEqual(response.count, 9) 21 | XCTAssertEqual(response.first?.name, "Crock Pot Roast") 22 | case .failure(let error): 23 | XCTFail("Should be success! Got: \(error)") 24 | } 25 | }) 26 | } 27 | 28 | func testGetRecipesShouldFail() { 29 | let session = MockedSession.simulate(failure: MockedSessionError.invalidResponse) { request in 30 | XCTAssertEqual(request.url?.absoluteString, "www.sample.com/sample/recipes.json") 31 | } 32 | 33 | ServiceFacade(configuration: configurate(session: session)) 34 | .getRecipes(completion: { result in 35 | switch result { 36 | case .success(_): 37 | XCTFail("Should be fail! Got success.") 38 | case .failure(let error): 39 | guard case MockedSessionError.invalidResponse = error else { 40 | XCTFail("Unexpected error!") 41 | return 42 | } 43 | } 44 | }) 45 | } 46 | 47 | private func configurate(session: SessionProtocol) -> Configuration { 48 | let service = Service(session: session, 49 | dispatcher: SyncDispatcher()) 50 | return Configuration(baseUrl: "www.sample.com", 51 | service: service) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /RecipesTests/FilterDataInteractorTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import RecipesApp 3 | 4 | class FitlerDataInteractorTests: XCTestCase { 5 | var presenter: MockedProductListPresenter? 6 | var sut: FilterDataInteractor? 7 | 8 | var mockedRecipe: Recipe { 9 | let ingredient = Ingredient(quantity: "1 1/2 tbsp", 10 | name: "olive oil", 11 | type: "Condiments") 12 | return Recipe(name: "Roasted Asparagus", 13 | ingredients: [ingredient], 14 | steps: ["Preheat oven to 425°F."], 15 | timers: [0, 10, 5, 15], 16 | imageURL: "http://sampleImage.url", 17 | originalURL: nil) 18 | } 19 | 20 | override func setUp() { 21 | presenter = MockedProductListPresenter() 22 | guard let presenter = presenter else { 23 | XCTFail("Mocks fail!") 24 | return 25 | } 26 | sut = FilterDataInteractor(presenter: presenter) 27 | } 28 | 29 | func testPerformShouldFilterWithResult() { 30 | let filter: (Recipe) -> Bool = { return 31 | $0.name.contains("Roasted") == true 32 | } 33 | presenter?.onRecipesHandler = { recipe in 34 | XCTAssertEqual(recipe.count, 1) 35 | } 36 | sut?.perform(filter: filter, 37 | on: [mockedRecipe]) 38 | XCTAssertEqual(presenter?.counterOnFiltered, 1) 39 | } 40 | 41 | func testPerformShouldFilterWithNoResult() { 42 | let filter: (Recipe) -> Bool = { return 43 | $0.name.contains("apple") == true 44 | } 45 | presenter?.onRecipesHandler = { recipe in 46 | XCTAssertEqual(recipe.count, 0) 47 | } 48 | sut?.perform(filter: filter, 49 | on: [mockedRecipe]) 50 | XCTAssertEqual(presenter?.counterOnFiltered, 1) 51 | } 52 | 53 | func testPerformNoRecipesShouldReturnNoResult() { 54 | let filter: (Recipe) -> Bool = { return 55 | $0.name.contains("Roasted") == true 56 | } 57 | presenter?.onRecipesHandler = { recipe in 58 | XCTAssertEqual(recipe.count, 0) 59 | } 60 | sut?.perform(filter: filter, 61 | on: nil) 62 | XCTAssertEqual(presenter?.counterOnFiltered, 1) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Recipes/Core/ServiceFacade.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum Result { 4 | case success(Value) 5 | case failure(Error) 6 | } 7 | 8 | protocol FacadeProtocol { 9 | func getRecipes(completion: @escaping ((Result<[Recipe]>) -> Void)) 10 | } 11 | 12 | struct ServiceFacade { 13 | private let configuration: Configuration 14 | 15 | var baseURL: URL? { 16 | return URL(string: configuration.baseUrl) 17 | } 18 | 19 | init(configuration: Configuration) { 20 | self.configuration = configuration 21 | } 22 | 23 | func makeRequest(_ url: URL?, 24 | map: T.Type, 25 | completion: @escaping ((Result) -> Void)) throws { 26 | 27 | guard let url = url else { 28 | throw ServiceError.wrongUrl 29 | } 30 | 31 | var request: URLRequest = URLRequest(url: url) 32 | request.httpMethod = Constants.URL.httpMethod 33 | 34 | if let data = configuration.service.object(for: url.absoluteString) as? T { 35 | configuration.service.dispatch { 36 | completion(Result.success(data)) 37 | } 38 | return 39 | } 40 | 41 | configuration 42 | .service 43 | .performTask(with: request) { (responseData, urlResponse, responseError) in 44 | completion(self.decode(response: responseData, 45 | map: map, 46 | error: responseError, 47 | url: url)) 48 | } 49 | } 50 | 51 | private func decode(response: Data?, 52 | map: T.Type, 53 | error: Error?, 54 | url: URL) -> (Result) { 55 | if let error = error { 56 | return (.failure(error)) 57 | } 58 | 59 | guard let jsonData = response else { 60 | return (.failure(ServiceError.noData)) 61 | } 62 | 63 | do { 64 | let decoded = try JSONDecoder().decode(map, 65 | from: jsonData) 66 | configuration.service.set(obj: decoded, for: url.absoluteString) 67 | return (.success(decoded)) 68 | } catch { 69 | return (.failure(error)) 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /RecipesTests/RecipesResponseTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import RecipesApp 3 | 4 | class RecipesTests: XCTestCase { 5 | 6 | func testRecipeResponse() { 7 | do { 8 | let recipe = try JSONUtil.loadClass(fromResource: "Recipe", ofType: Recipe.self) 9 | XCTAssertEqual(recipe?.name, "Crock Pot Roast") 10 | XCTAssertEqual(recipe?.ingredients.count, 5) 11 | XCTAssertEqual(recipe?.ingredients.first?.quantity, "1") 12 | XCTAssertEqual(recipe?.ingredients.first?.name, " beef roast") 13 | XCTAssertEqual(recipe?.ingredients.first?.type, "Meat") 14 | XCTAssertEqual(recipe?.steps.count, 4) 15 | XCTAssertEqual(recipe?.steps.first, "Place beef roast in crock pot.") 16 | XCTAssertEqual(recipe?.timers.count, 4) 17 | XCTAssertEqual(recipe?.timers.last, 420) 18 | XCTAssertEqual(recipe?.imageURL, "http://img.sndimg.com/food/image/upload/w_266/v1/img/recipes/27/20/8/picVfzLZo.jpg") 19 | XCTAssertEqual(recipe?.originalURL, "http://www.food.com/recipe/to-die-for-crock-pot-roast-27208") 20 | } catch { 21 | XCTFail("Failed to decode: \(error)") 22 | } 23 | } 24 | 25 | func testRecipesResponse() { 26 | do { 27 | let recipes = try JSONUtil.loadClass(fromResource: "Recipes", ofType: [Recipe].self) 28 | XCTAssertEqual(recipes?.count, 9) 29 | let recipe = recipes?.first 30 | XCTAssertEqual(recipe?.name, "Crock Pot Roast") 31 | XCTAssertEqual(recipe?.ingredients.count, 5) 32 | XCTAssertEqual(recipe?.ingredients.first?.quantity, "1") 33 | XCTAssertEqual(recipe?.ingredients.first?.name, " beef roast") 34 | XCTAssertEqual(recipe?.ingredients.first?.type, "Meat") 35 | XCTAssertEqual(recipe?.steps.count, 4) 36 | XCTAssertEqual(recipe?.steps.first, "Place beef roast in crock pot.") 37 | XCTAssertEqual(recipe?.timers.count, 4) 38 | XCTAssertEqual(recipe?.timers.last, 420) 39 | XCTAssertEqual(recipe?.imageURL, "http://img.sndimg.com/food/image/upload/w_266/v1/img/recipes/27/20/8/picVfzLZo.jpg") 40 | XCTAssertEqual(recipe?.originalURL, "http://www.food.com/recipe/to-die-for-crock-pot-roast-27208") 41 | 42 | XCTAssertNil(recipes?.dropFirst().dropFirst().first?.originalURL) 43 | } catch { 44 | XCTFail("Failed to decode: \(error)") 45 | } 46 | } 47 | } 48 | 49 | -------------------------------------------------------------------------------- /Recipes/Product List/ProductListPresenter.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | class ProductListPresenter { 5 | weak var view: ProductListViewProtocol? 6 | var fetchData: FetchDataInteractorProtocol? 7 | var filterData: FilterDataInteractorProtocol? 8 | 9 | private var recipe: Recipe? 10 | private var recipes: [Recipe]? 11 | } 12 | 13 | extension ProductListPresenter: ProductListPresenterProtocol { 14 | func fetchRecipes() { 15 | fetchData?.perform() 16 | } 17 | 18 | func filter(by text: String) { 19 | filterData?.perform(filter: by(text: text), 20 | on: recipes) 21 | } 22 | 23 | func filter(by time: (min: Int, max: Int)) { 24 | filterData?.perform(filter: by(range: time), 25 | on: recipes) 26 | } 27 | 28 | func filter(by ingredients: (Int, Int), steps: (Int, Int), timer: (Int, Int)) { 29 | filterData?.perform(filter: by(ingredients: ingredients, 30 | steps: steps, 31 | timer: timer), 32 | on: recipes) 33 | } 34 | 35 | func select(recipe: Recipe?) { 36 | self.recipe = recipe 37 | push() 38 | } 39 | 40 | func reset() { 41 | view?.show(recipes: recipes ?? []) 42 | } 43 | 44 | func on(recipes: [Recipe]) { 45 | self.recipes = recipes 46 | view?.show(recipes: recipes) 47 | } 48 | 49 | func on(filtered: [Recipe]) { 50 | view?.show(filtered: filtered) 51 | } 52 | 53 | func on(error: Error) { 54 | view?.show(error: error) 55 | } 56 | 57 | func by(text: String) -> (Recipe) -> Bool { 58 | let filter: (Recipe) -> Bool = { return 59 | $0.name.lowercased().contains(text.lowercased()) == true || 60 | $0.ingredients.filter({ (ingredient: Ingredient) in 61 | return ingredient.name.lowercased().contains(text.lowercased()) 62 | }).count > 0 || 63 | $0.steps.filter({ (step: String) in 64 | return step.lowercased().contains(text.lowercased()) 65 | }).count > 0 66 | } 67 | return filter 68 | } 69 | 70 | func by(range: ((Int, Int))) -> (Recipe) -> Bool { 71 | let filter: (Recipe) -> Bool = { recipe in 72 | let sum = recipe.timers.reduce(0, +) 73 | return (sum >= range.0 && sum <= range.1) 74 | } 75 | return filter 76 | } 77 | 78 | func by(ingredients: (Int, Int), steps: (Int, Int), timer: (Int, Int)) -> (Recipe) -> Bool { 79 | let filter: (Recipe) -> Bool = { recipe in 80 | let ig = (recipe.ingredients.count >= ingredients.0 && recipe.ingredients.count <= ingredients.1) 81 | let sp = (recipe.steps.count >= steps.0 && recipe.steps.count <= steps.1) 82 | let tm = self.by(range: timer)(recipe) 83 | return ig && sp && tm 84 | } 85 | return filter 86 | } 87 | } 88 | 89 | private extension ProductListPresenter { 90 | func push() { 91 | ProductPageRouter(recipe: self).view 92 | .flatMap({ view.flatMap({ controller in return controller as? UIViewController })? 93 | .navigationController?.pushViewController($0, animated: true) }) 94 | } 95 | } 96 | 97 | extension ProductListPresenter: RecipeProtocol { 98 | func fetch() -> Recipe? { 99 | return recipe 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /RecipesTests/ProductListPresenterTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import RecipesApp 3 | 4 | class ProductListPresenterTests: XCTestCase { 5 | var fetchData: MockedFetchDataInteractor? 6 | var filterData: MockedFilterDataInteractor? 7 | 8 | var view: MockedProductListView? 9 | var sut: ProductListPresenter? 10 | 11 | var mockedRecipe: Recipe { 12 | let ingredient = Ingredient(quantity: "1 1/2 tbsp", 13 | name: "olive oil", 14 | type: "Condiments") 15 | return Recipe(name: "Roasted Asparagus", 16 | ingredients: [ingredient], 17 | steps: ["Preheat oven to 425°F."], 18 | timers: [0, 10, 5, 15], 19 | imageURL: "http://sampleImage.url", 20 | originalURL: nil) 21 | } 22 | 23 | override func setUp() { 24 | sut = ProductListPresenter() 25 | view = MockedProductListView() 26 | fetchData = MockedFetchDataInteractor() 27 | filterData = MockedFilterDataInteractor() 28 | 29 | sut?.fetchData = fetchData 30 | sut?.filterData = filterData 31 | sut?.view = view 32 | } 33 | 34 | func testFetchRecipesShouldPerform() { 35 | sut?.fetchRecipes() 36 | XCTAssertEqual(fetchData?.counterPerform, 1) 37 | } 38 | 39 | func testFilterByTextShouldPerform() { 40 | sut?.filter(by: "") 41 | XCTAssertEqual(filterData?.counterPerform, 1) 42 | } 43 | 44 | func testFilterByRangeShouldPerform() { 45 | sut?.filter(by: (0, 0)) 46 | XCTAssertEqual(filterData?.counterPerform, 1) 47 | } 48 | 49 | func testFilterByDifficultyShoudlPerform() { 50 | sut?.filter(by: (0,0), steps: (0,0), timer: (0,0)) 51 | XCTAssertEqual(filterData?.counterPerform, 1) 52 | } 53 | 54 | func testResetShouldPerform() { 55 | sut?.reset() 56 | XCTAssertEqual(view?.counterShowRecipes, 1) 57 | } 58 | 59 | func testOnRecipesShouldShowRecipes() { 60 | sut?.on(recipes: []) 61 | XCTAssertEqual(view?.counterShowRecipes, 1) 62 | } 63 | 64 | func testOnFilteredShouldShowFiltered() { 65 | sut?.on(filtered: []) 66 | XCTAssertEqual(view?.counterShowFiltered, 1) 67 | } 68 | 69 | func testOnErrorShouldCallShowError() { 70 | sut?.on(error: MockedError.fake) 71 | XCTAssertEqual(view?.counterShowError, 1) 72 | } 73 | 74 | func testByTextIntoNameShouldSuccess() { 75 | let result = sut?.by(text: "roasted") ?? { _ in return false } 76 | XCTAssertTrue(result(mockedRecipe)) 77 | } 78 | 79 | func testByTextIntoIngredientShouldSuccess() { 80 | let result = sut?.by(text: "oil") ?? { _ in return false } 81 | XCTAssertTrue(result(mockedRecipe)) 82 | } 83 | 84 | func testByTextIntoStepsShouldSuccess() { 85 | let result = sut?.by(text: "425") ?? { _ in return false } 86 | XCTAssertTrue(result(mockedRecipe)) 87 | } 88 | 89 | func testByTextShouldFail() { 90 | let result = sut?.by(text: "apple") ?? { _ in return false } 91 | XCTAssertFalse(result(mockedRecipe)) 92 | } 93 | 94 | func testByRangeIntoTimerShouldSuccess() { 95 | let result = sut?.by(range: (10, 30)) ?? { _ in return false } 96 | XCTAssertTrue(result(mockedRecipe)) 97 | } 98 | 99 | func testByRangeIntoTimerShouldFail() { 100 | let result = sut?.by(range: (5, 20)) ?? { _ in return false } 101 | XCTAssertFalse(result(mockedRecipe)) 102 | } 103 | 104 | func testByDifficultyShouldSuccess() { 105 | let result = sut?.by(ingredients: (1, 5), steps: (1, 5), timer: (5, 40)) ?? { _ in return false } 106 | XCTAssertTrue(result(mockedRecipe)) 107 | } 108 | 109 | func testByDifficultyShouldFail() { 110 | let result = sut?.by(ingredients: (1, 5), steps: (1, 5), timer: (5, 10)) ?? { _ in return false } 111 | XCTAssertFalse(result(mockedRecipe)) 112 | } 113 | } 114 | 115 | private enum MockedError: Error { 116 | case fake 117 | } 118 | -------------------------------------------------------------------------------- /RecipesApp.xcodeproj/xcshareddata/xcschemes/RecipesApp.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 33 | 39 | 40 | 41 | 42 | 43 | 49 | 50 | 51 | 52 | 53 | 54 | 64 | 66 | 72 | 73 | 74 | 75 | 76 | 77 | 83 | 85 | 91 | 92 | 93 | 94 | 96 | 97 | 100 | 101 | 102 | -------------------------------------------------------------------------------- /RecipesTests/Mocks/MockedProductListPresenter.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | @testable import RecipesApp 3 | 4 | class MockedProductListPresenter: ProductListPresenterProtocol { 5 | var counterFetchRecipes: Int = 0 6 | var counterFilterByText: Int = 0 7 | var counterFilterByTime: Int = 0 8 | var counterFilterByDifficulty: Int = 0 9 | var counterReset: Int = 0 10 | var counterSelectRecipe: Int = 0 11 | var counterOnRecipes: Int = 0 12 | var counterOnFiltered: Int = 0 13 | var counterOnError: Int = 0 14 | var counterByText: Int = 0 15 | var counterByRange: Int = 0 16 | var counterByDifficulty: Int = 0 17 | 18 | var fetchRecipesHandler: (() -> Void)? 19 | var filterByTextHandler: ((String) -> Void)? 20 | var filterByTimeHandler: (((Int, Int)) -> Void)? 21 | var filterByDifficultyHandler: (((Int, Int), (Int, Int), (Int, Int)) -> Void)? 22 | var resetHandler: (() -> Void)? 23 | var selectRecipeHandler: ((Recipe?) -> Void)? 24 | var onRecipesHandler: (([Recipe]) -> Void)? 25 | var onFilteredHandler: (([Recipe]) -> Void)? 26 | var onErrorHandler: ((Error) -> Void)? 27 | var byTextHandler: ((String) -> (Recipe) -> Bool)? 28 | var byRangeHandler: (((Int, Int)) -> (Recipe) -> Bool)? 29 | var byDifficultyHandler: (((Int, Int), (Int, Int), (Int, Int)) -> (Recipe) -> Bool)? 30 | 31 | func fetchRecipes() { 32 | counterFetchRecipes += 1 33 | if let fetchRecipesHandler = fetchRecipesHandler { 34 | return fetchRecipesHandler() 35 | } 36 | } 37 | 38 | func filter(by text: String) { 39 | counterFilterByText += 1 40 | if let filterByTextHandler = filterByTextHandler { 41 | return filterByTextHandler(text) 42 | } 43 | } 44 | 45 | func filter(by time: (min: Int, max: Int)) { 46 | counterFilterByTime += 1 47 | if let filterByTimeHandler = filterByTimeHandler { 48 | return filterByTimeHandler(time) 49 | } 50 | } 51 | 52 | func filter(by ingredients: (Int, Int), steps: (Int, Int), timer: (Int, Int)) { 53 | counterFilterByDifficulty += 1 54 | if let filterByDifficultyHandler = filterByDifficultyHandler { 55 | return filterByDifficultyHandler(ingredients, steps, timer) 56 | } 57 | } 58 | 59 | func reset() { 60 | counterReset += 1 61 | if let resetHandler = resetHandler { 62 | return resetHandler() 63 | } 64 | } 65 | 66 | func select(recipe: Recipe?) { 67 | counterSelectRecipe += 1 68 | if let selectRecipeHandler = selectRecipeHandler { 69 | return selectRecipeHandler(recipe) 70 | } 71 | } 72 | 73 | func on(recipes: [Recipe]) { 74 | counterOnRecipes += 1 75 | if let onRecipesHandler = onRecipesHandler { 76 | return onRecipesHandler(recipes) 77 | } 78 | } 79 | 80 | func on(filtered: [Recipe]) { 81 | counterOnFiltered += 1 82 | if let onFilteredHandler = onFilteredHandler { 83 | return onFilteredHandler(filtered) 84 | } 85 | } 86 | 87 | func on(error: Error) { 88 | counterOnError += 1 89 | if let onErrorHandler = onErrorHandler { 90 | return onErrorHandler(error) 91 | } 92 | } 93 | 94 | func by(text: String) -> (Recipe) -> Bool { 95 | counterByText += 1 96 | if let byTextHandler = byTextHandler { 97 | return byTextHandler(text) 98 | } 99 | return { _ in return false } 100 | } 101 | 102 | func by(range: (Int, Int)) -> (Recipe) -> Bool { 103 | counterByRange += 1 104 | if let byRangeHandler = byRangeHandler { 105 | return byRangeHandler(range) 106 | } 107 | return { _ in return false } 108 | } 109 | 110 | func by(ingredients: (Int, Int), steps: (Int, Int), timer: (Int, Int)) -> (Recipe) -> Bool { 111 | counterByDifficulty += 1 112 | if let byDifficultyHandler = byDifficultyHandler { 113 | return byDifficultyHandler(ingredients, steps, timer) 114 | } 115 | return { _ in return false } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /Recipes/ProductPage/ProductPageViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class ProductPageViewController: UIViewController { 4 | var presenter: ProductPagePresenterProtocol? 5 | 6 | private var cellCallbacks: [((Int) -> Bool, (UITableView, IndexPath) -> UITableViewCell)]? 7 | private var rowsCallbacks: [((Int) -> Bool, () -> Int)]? 8 | private var titleCallbacks: [((Int) -> Bool, () -> String)]? 9 | 10 | @IBOutlet private weak var tableView: UITableView? 11 | @IBOutlet private weak var imageView: UIImageView? 12 | 13 | var imageService: ImageProtocol? 14 | 15 | var recipe: Recipe? { 16 | didSet { 17 | tableView?.reloadData() 18 | } 19 | } 20 | 21 | override func viewDidLoad() { 22 | super.viewDidLoad() 23 | presenter?.performFetch() 24 | } 25 | 26 | private func setup() { 27 | navigationController?.navigationBar.prefersLargeTitles = true 28 | navigationController?.navigationItem.leftItemsSupplementBackButton = true 29 | } 30 | 31 | private func callbacks() { 32 | cellCallbacks = [({ section in return section == 0 }, ingredientCell), 33 | ({ section in return section == 1 }, stepCell), 34 | ({ section in return section == 2 }, timerCell)] 35 | rowsCallbacks = [({ section in return section == 0 }, numberOfIngredients), 36 | ({ section in return section == 1 }, numberOfSteps), 37 | ({ section in return section == 2 }, numberOfTimers)] 38 | titleCallbacks = [({ section in return section == 0 }, titleIngredients), 39 | ({ section in return section == 1 }, titleSteps), 40 | ({ section in return section == 2 }, titleTimers)] 41 | } 42 | } 43 | 44 | extension ProductPageViewController: ProductPageViewProtocol { 45 | func loadCallbacks() { 46 | callbacks() 47 | } 48 | 49 | func load(recipe: Recipe?) { 50 | self.recipe = recipe 51 | imageService?.downloadImage(from: recipe?.imageURL) { image in 52 | self.imageView?.image = image 53 | } 54 | self.title = recipe?.name 55 | } 56 | } 57 | 58 | extension ProductPageViewController: UITableViewDelegate, UITableViewDataSource { 59 | func numberOfSections(in tableView: UITableView) -> Int { 60 | return 3 61 | } 62 | 63 | func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 64 | return rowsCallbacks?.first(where: { $0.0(section) })?.1() ?? 0 65 | } 66 | 67 | func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 68 | return cellCallbacks?.first(where: { $0.0(indexPath.section) })?.1(tableView, indexPath) ?? UITableViewCell() 69 | } 70 | 71 | func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { 72 | return titleCallbacks?.first(where: { $0.0(section) })?.1() ?? "" 73 | } 74 | } 75 | 76 | private extension ProductPageViewController { 77 | func ingredientCell(_ tableView: UITableView, indexPath: IndexPath) -> UITableViewCell { 78 | let cell = tableView.dequeueReusableCell(withIdentifier: Constants.Cell.productPageCell) 79 | let ingredient = recipe?.ingredients[indexPath.row] 80 | cell?.textLabel?.text = ingredient?.name 81 | cell?.detailTextLabel?.text = ingredient?.quantity 82 | return cell ?? UITableViewCell() 83 | } 84 | 85 | func stepCell(_ tableView: UITableView, indexPath: IndexPath) -> UITableViewCell { 86 | let cell = tableView.dequeueReusableCell(withIdentifier: Constants.Cell.productPageCell) 87 | let step = recipe?.steps[indexPath.row] 88 | cell?.textLabel?.text = "\(Constants.Title.step) \(indexPath.row + 1): \(step ?? "")" 89 | cell?.detailTextLabel?.text = "" 90 | return cell ?? UITableViewCell() 91 | } 92 | 93 | func timerCell(_ tableView: UITableView, indexPath: IndexPath) -> UITableViewCell { 94 | let cell = tableView.dequeueReusableCell(withIdentifier: Constants.Cell.productPageCell) 95 | let timer = recipe?.timers[indexPath.row] 96 | cell?.textLabel?.text = "\(Constants.Title.step) \(indexPath.row + 1): \(timer ?? 0)\(Constants.Title.minutes)" 97 | cell?.detailTextLabel?.text = "" 98 | return cell ?? UITableViewCell() 99 | } 100 | } 101 | 102 | private extension ProductPageViewController { 103 | func numberOfIngredients() -> Int { 104 | return recipe?.ingredients.count ?? 0 105 | } 106 | 107 | func numberOfSteps() -> Int { 108 | return recipe?.steps.count ?? 0 109 | } 110 | 111 | func numberOfTimers() -> Int { 112 | return recipe?.timers.count ?? 0 113 | } 114 | } 115 | 116 | private extension ProductPageViewController { 117 | func titleIngredients() -> String { 118 | return "\(Constants.Title.ingredients.uppercased()) \(recipe?.ingredients.count ?? 0)" 119 | } 120 | 121 | func titleSteps() -> String { 122 | return Constants.Title.instructions.uppercased() 123 | } 124 | 125 | func titleTimers() -> String { 126 | return Constants.Title.time.uppercased() 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RECIPES 2 | 3 | Recipes is an iOS application oriented toward the following patterns: 4 | 5 | ✅ VIPER Architecture 6 | 7 | ✅ Protocol Oriented 8 | 9 | ✅ Functional Programming 10 | 11 | ✅ Clean Code 12 | 13 | ✅ Dependency Injection 14 | 15 | ✅ Unit Tests 16 | 17 | It's based on a `GET` api and built over a `UICollectionView` and detailed `UITableView`. 18 | 19 | 20 | ## HOW IT WORKS 21 | 22 | Each controller is built by 4 files 23 | 1. Router (routing layer) 24 | 2. Presenter (view logic) 25 | 3. Interactor (business logic for a use case) 26 | 4. View (display data) 27 | 28 | 29 | ### CONFIGURATION 30 | 31 | The routing layer performs the injection: 32 | 33 | 🔸 Presenter 34 | 35 | 🔸 View 36 | 37 | 🔸 Interactor 38 | ``` 39 | view = UIStoryboard(name: Constants.Storyboard.name, bundle: nil) 40 | .instantiateViewController(withIdentifier: "productListViewController") as? ProductListViewController 41 | 42 | let presenter = ProductListPresenter() 43 | view?.presenter = presenter 44 | view?.imageService = imageService() 45 | presenter.view = view 46 | 47 | let fetchData = FetchDataInteractor(service: serviceFacade(), 48 | presenter: presenter) 49 | presenter.fetchData = fetchData 50 | 51 | let filterData = FilterDataInteractor(presenter: presenter) 52 | presenter.filterData = filterData 53 | ``` 54 | 55 | ... building the main services of the application: 56 | 57 | 🔸 Cache facade 58 | ``` 59 | let cache = CacheFacade() 60 | ``` 61 | 62 | 🔸 Service facade 63 | 64 | ``` 65 | private func serviceFacade() -> FacadeProtocol { 66 | let service = Service(session: Session(), 67 | cache: CacheFacade()) 68 | let config = Configuration(baseUrl: Constants.URL.baseUrl, 69 | service: service) 70 | return ServiceFacade(configuration: config) 71 | } 72 | ``` 73 | 🔸 Image service 74 | ``` 75 | private func imageService() -> ImageProtocol { 76 | let service = ImageService(cache: CacheFacade()) 77 | return ImageFacade(configuration: service) 78 | } 79 | 80 | ``` 81 | 82 | ### VIPER FLOW 83 | 84 | 1. View calls Presenter: 85 | 86 | ``` 87 | override func viewDidLoad() { 88 | super.viewDidLoad() 89 | presenter?.fetchRecipes() 90 | } 91 | ``` 92 | 93 | 2. Presenter performs Interactor call 94 | 95 | ``` 96 | func fetchRecipes() { 97 | fetchData?.perform() 98 | } 99 | ``` 100 | 101 | 3. Interactor executes "business logic" and notifies Presenter 102 | 103 | ``` 104 | func perform() { 105 | // ... 106 | // bla bla bla 107 | // ... 108 | 109 | self.presenter.on(recipes: response) 110 | } 111 | ``` 112 | 113 | 4. Presenter revices data from Interactor and notifies View 114 | 115 | ``` 116 | func on(recipes: [Recipe]) { 117 | view?.show(recipes: recipes) 118 | } 119 | ``` 120 | 121 | 5. View updates the UI 122 | ``` 123 | func show(recipes: [Recipe]) { 124 | // bla bla bla 125 | } 126 | ``` 127 | 128 | ### TESTS 129 | 130 | Each module is unit tested (mocks oriented): decoding, mapping, services, presenter, interactor and view (and utilies for sure). 131 | 132 | 1. Presenter sample test 133 | 134 | ``` 135 | func testFetchRecipesShouldPerform() { 136 | sut?.fetchRecipes() 137 | XCTAssertEqual(fetchData?.counterPerform, 1) 138 | } 139 | ``` 140 | 141 | ``` 142 | class MockedFilterDataInteractor: FilterDataInteractorProtocol { 143 | var counterPerform: Int = 0 144 | 145 | var performHandler: (((Recipe) -> Bool, [Recipe]?) -> Void)? 146 | 147 | func perform(filter: (Recipe) -> Bool, 148 | on recipes: [Recipe]?) { 149 | counterPerform += 1 150 | if let performHandler = performHandler { 151 | return performHandler(filter, recipes) 152 | } 153 | } 154 | } 155 | ``` 156 | 157 | 2. Service sample test 158 | 159 | ``` 160 | func testGetRecipesShouldSuccess() { 161 | guard let data = JSONUtil.loadData(fromResource: "Recipes") else { 162 | XCTFail("JSON data error!") 163 | return 164 | } 165 | let session = MockedSession.simulate(success: data) { request in 166 | XCTAssertEqual(request.url?.absoluteString, "www.sample.com/sampleapifortest/recipes.json") 167 | } 168 | 169 | ServiceFacade(configuration: configurate(session: session)) 170 | .getRecipes(completion: { result in 171 | switch result { 172 | case .success(let response): 173 | XCTAssertNotNil(response) 174 | XCTAssertEqual(response.count, 9) 175 | XCTAssertEqual(response.first?.name, "Crock Pot Roast") 176 | case .failure(let error): 177 | XCTFail("Should be success! Got: \(error)") 178 | } 179 | }) 180 | } 181 | ``` 182 | 183 | 3. Decoding/Mapping sample tests 184 | 185 | ``` 186 | func testRecipeResponse() { 187 | do { 188 | let recipe = try JSONUtil.loadClass(fromResource: "Recipe", ofType: Recipe.self) 189 | XCTAssertEqual(recipe?.name, "Crock Pot Roast") 190 | XCTAssertEqual(recipe?.ingredients.count, 5) 191 | XCTAssertEqual(recipe?.ingredients.first?.quantity, "1") 192 | XCTAssertEqual(recipe?.ingredients.first?.name, " beef roast") 193 | XCTAssertEqual(recipe?.ingredients.first?.type, "Meat") 194 | XCTAssertEqual(recipe?.steps.count, 4) 195 | XCTAssertEqual(recipe?.steps.first, "Place beef roast in crock pot.") 196 | XCTAssertEqual(recipe?.timers.count, 4) 197 | XCTAssertEqual(recipe?.timers.last, 420) 198 | XCTAssertEqual(recipe?.imageURL, "http://img.sndimg.com/food/image/upload/w_266/v1/img/recipes/27/20/8/picVfzLZo.jpg") 199 | XCTAssertEqual(recipe?.originalURL, "http://www.food.com/recipe/to-die-for-crock-pot-roast-27208") 200 | } catch { 201 | XCTFail("Failed to decode: \(error)") 202 | } 203 | } 204 | ``` 205 | 206 | ## CONTRIBUTORS 207 | Any suggestions are welcome 👨🏻‍💻 208 | 209 | ## REQUIREMENTS 210 | • Swift 4.2 211 | 212 | • Xcode 10 213 | -------------------------------------------------------------------------------- /Recipes/Product List/ProductListViewController.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | private extension ProductListViewController { 5 | private typealias Times = [( 6 | title: String, 7 | range: (Int, Int))] 8 | private typealias Difficulty = [( 9 | title: String, 10 | ingredients: (Int, Int), 11 | steps: (Int, Int), 12 | timer: (Int, Int))] 13 | private static let timesFilter: Times = [(Constants.Time.low, (0, 10)), 14 | (Constants.Time.medium, (10, 20)), 15 | (Constants.Time.high, (20, 1000))] 16 | private static let difficultyFilter: Difficulty = [(Constants.Difficulty.easy, (0, 15), (0, 15), (0, 25)), 17 | (Constants.Difficulty.medium, (0, 10), (5, 20), (20, 60)), 18 | (Constants.Difficulty.hard, (0, 10), (5, 20), (60, 1000))] 19 | } 20 | 21 | final class ProductListViewController: UIViewController { 22 | @IBOutlet private weak var collectionView: UICollectionView? 23 | 24 | private var selected: Recipe? 25 | private var sectionCallbacks: [(() -> Bool, () -> Int)]? 26 | private var cellCallbacks: [(() -> Bool, (IndexPath) -> Recipe?)]? 27 | 28 | var presenter: ProductListPresenterProtocol? 29 | var imageService: ImageProtocol? 30 | 31 | private var recipes: [Recipe]? { 32 | didSet { 33 | collectionView?.reloadData() 34 | } 35 | } 36 | private var filtered: [Recipe]? { 37 | didSet { 38 | collectionView?.reloadData() 39 | } 40 | } 41 | private var isFilter = false 42 | private var isSearch: Bool { 43 | return (navigationItem.searchController?.searchBar.isFirstResponder ?? false) || isFilter 44 | } 45 | 46 | override func viewDidLoad() { 47 | super.viewDidLoad() 48 | setup() 49 | callbacks() 50 | presenter?.fetchRecipes() 51 | } 52 | 53 | private func setup() { 54 | self.title = Constants.Title.recipes 55 | navigationController?.navigationBar.prefersLargeTitles = true 56 | navigationItem.searchController = UISearchController(searchResultsController: nil) 57 | navigationItem.searchController?.obscuresBackgroundDuringPresentation = false 58 | navigationItem.hidesSearchBarWhenScrolling = true 59 | navigationItem.searchController?.searchResultsUpdater = self 60 | navigationItem.searchController?.searchBar.placeholder = "Search" 61 | definesPresentationContext = true 62 | } 63 | 64 | private func callbacks() { 65 | sectionCallbacks = [ 66 | ({ return self.isSearch }, { return self.filtered?.count ?? 0 }), 67 | ({ return true }, { return self.recipes?.count ?? 0 }) 68 | ] 69 | cellCallbacks = [ 70 | ({ return self.isSearch }, { indexPath in return self.filtered?[indexPath.row] }), 71 | ({ return true }, { indexPath in return self.recipes?[indexPath.row] }), 72 | ] 73 | } 74 | } 75 | 76 | extension ProductListViewController: ProductListViewProtocol { 77 | func show(recipes: [Recipe]) { 78 | self.recipes = recipes 79 | } 80 | 81 | func show(filtered: [Recipe]) { 82 | self.filtered = filtered 83 | } 84 | 85 | func show(error: Error) { 86 | print(error) 87 | } 88 | } 89 | 90 | extension ProductListViewController: UICollectionViewDelegate, UICollectionViewDataSource { 91 | func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { 92 | return sectionCallbacks?.first(where: { $0.0() })?.1() ?? 0 93 | } 94 | 95 | func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { 96 | let cell = collectionView.dequeueReusableCell(withReuseIdentifier: Constants.Cell.productListCell, for: indexPath) as? ProductListCell 97 | 98 | cell?.imageService = imageService 99 | cell?.recipe = cellCallbacks?.first(where: { $0.0() })?.1(indexPath) 100 | 101 | return cell ?? ProductListCell() 102 | } 103 | 104 | func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { 105 | collectionView.deselectItem(at: indexPath, animated: true) 106 | presenter?.select(recipe: cellCallbacks?.first(where: { $0.0() })?.1(indexPath)) 107 | } 108 | } 109 | 110 | extension ProductListViewController: UICollectionViewDelegateFlowLayout { 111 | func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { 112 | return CGSize(width: self.view.frame.width*0.4, height: 190) 113 | } 114 | } 115 | 116 | extension ProductListViewController: UISearchResultsUpdating { 117 | func updateSearchResults(for searchController: UISearchController) { 118 | presenter?.filter(by: searchController.searchBar.text ?? "") 119 | } 120 | } 121 | 122 | extension ProductListViewController { 123 | @IBAction func onByDifficulty(_ sender : AnyObject){ 124 | let times: (UIAlertController) -> Void = { alert in 125 | self.byDifficulty(alert: alert) 126 | 127 | } 128 | UIAlertController.show(message: Constants.Title.difficulty, 129 | actions: times, 130 | in: self) 131 | } 132 | 133 | @IBAction func onByTime(_ sender : AnyObject){ 134 | let times: (UIAlertController) -> Void = { alert in 135 | self.byTime(alert: alert) 136 | } 137 | UIAlertController.show(message: Constants.Title.range, 138 | actions: times, 139 | in: self) 140 | } 141 | } 142 | 143 | private extension ProductListViewController { 144 | func byTime(alert: UIAlertController) { 145 | ProductListViewController.timesFilter.forEach({ time in 146 | let first = UIAlertAction(title: time.0, style: .default) { _ in 147 | self.isFilter = true 148 | self.presenter?.filter(by: time.1) 149 | } 150 | return alert.addAction(first) 151 | }) 152 | let reset = UIAlertAction(title: "Reset", 153 | style: UIAlertAction.Style.destructive) { _ in 154 | self.isFilter = false 155 | self.presenter?.reset() 156 | } 157 | alert.addAction(reset) 158 | } 159 | 160 | func byDifficulty(alert: UIAlertController) { 161 | ProductListViewController.difficultyFilter.forEach({ difficulty in 162 | let first = UIAlertAction(title: difficulty.title, style: .default) { _ in 163 | self.isFilter = true 164 | self.presenter?.filter(by: difficulty.ingredients, 165 | steps: difficulty.steps, 166 | timer: difficulty.timer) 167 | } 168 | return alert.addAction(first) 169 | }) 170 | let reset = UIAlertAction(title: Constants.Title.reset, 171 | style: UIAlertAction.Style.destructive) { _ in 172 | self.isFilter = false 173 | self.presenter?.reset() 174 | } 175 | alert.addAction(reset) 176 | } 177 | } 178 | 179 | -------------------------------------------------------------------------------- /RecipesApp.xcodeproj/xcuserdata/mattiacantalu.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 8 | 20 | 21 | 22 | 24 | 36 | 37 | 38 | 40 | 52 | 53 | 54 | 56 | 68 | 69 | 70 | 72 | 84 | 85 | 86 | 88 | 100 | 101 | 102 | 104 | 116 | 117 | 118 | 120 | 132 | 133 | 134 | 136 | 148 | 149 | 150 | 152 | 164 | 165 | 166 | 168 | 180 | 181 | 195 | 196 | 210 | 211 | 212 | 213 | 214 | 216 | 228 | 229 | 243 | 244 | 258 | 259 | 260 | 261 | 262 | 264 | 276 | 277 | 278 | 279 | 280 | -------------------------------------------------------------------------------- /Recipes/Base.lproj/Main.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 | 46 | 52 | 58 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 151 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | -------------------------------------------------------------------------------- /RecipesTests/Mocks/Recipes.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "Crock Pot Roast", 4 | "ingredients": [ 5 | { 6 | "quantity": "1", 7 | "name": " beef roast", 8 | "type": "Meat" 9 | }, 10 | { 11 | "quantity": "1 package", 12 | "name": "brown gravy mix", 13 | "type": "Baking" 14 | }, 15 | { 16 | "quantity": "1 package", 17 | "name": "dried Italian salad dressing mix", 18 | "type": "Condiments" 19 | }, 20 | { 21 | "quantity": "1 package", 22 | "name": "dry ranch dressing mix", 23 | "type": "Condiments" 24 | }, 25 | { 26 | "quantity": "1/2 cup", 27 | "name": "water", 28 | "type": "Drinks" 29 | } 30 | ], 31 | "steps": [ 32 | "Place beef roast in crock pot.", 33 | "Mix the dried mixes together in a bowl and sprinkle over the roast.", 34 | "Pour the water around the roast.", 35 | "Cook on low for 7-9 hours." 36 | ], 37 | "timers": [ 38 | 0, 39 | 0, 40 | 0, 41 | 420 42 | ], 43 | "imageURL": "http://img.sndimg.com/food/image/upload/w_266/v1/img/recipes/27/20/8/picVfzLZo.jpg", 44 | "originalURL": "http://www.food.com/recipe/to-die-for-crock-pot-roast-27208" 45 | }, 46 | { 47 | "name": "Roasted Asparagus", 48 | "ingredients": [ 49 | { 50 | "quantity": "1 lb", 51 | "name": " asparagus", 52 | "type": "Produce" 53 | }, 54 | { 55 | "quantity": "1 1/2 tbsp", 56 | "name": "olive oil", 57 | "type": "Condiments" 58 | }, 59 | { 60 | "quantity": "1/2 tsp", 61 | "name": "kosher salt", 62 | "type": "Baking" 63 | } 64 | ], 65 | "steps": [ 66 | "Preheat oven to 425°F.", 67 | "Cut off the woody bottom part of the asparagus spears and discard.", 68 | "With a vegetable peeler, peel off the skin on the bottom 2-3 inches of the spears (this keeps the asparagus from being all.\",string.\", and if you eat asparagus you know what I mean by that).", 69 | "Place asparagus on foil-lined baking sheet and drizzle with olive oil.", 70 | "Sprinkle with salt.", 71 | "With your hands, roll the asparagus around until they are evenly coated with oil and salt.", 72 | "Roast for 10-15 minutes, depending on the thickness of your stalks and how tender you like them.", 73 | "They should be tender when pierced with the tip of a knife.", 74 | "The tips of the spears will get very brown but watch them to prevent burning.", 75 | "They are great plain, but sometimes I serve them with a light vinaigrette if we need something acidic to balance out our meal." 76 | ], 77 | "timers": [ 78 | 0, 79 | 0, 80 | 0, 81 | 0, 82 | 0, 83 | 0, 84 | 10, 85 | 0, 86 | 0, 87 | 0 88 | ], 89 | "imageURL": "http://img.sndimg.com/food/image/upload/w_266/v1/img/recipes/50/84/7/picMcSyVd.jpg", 90 | "originalURL": "http://www.food.com/recipe/roasted-asparagus-50847" 91 | }, 92 | { 93 | "name": "Curried Lentils and Rice", 94 | "ingredients": [ 95 | { 96 | "quantity": "1 quart", 97 | "name": "beef broth", 98 | "type": "Misc" 99 | }, 100 | { 101 | "quantity": "1 cup", 102 | "name": "dried green lentils", 103 | "type": "Misc" 104 | }, 105 | { 106 | "quantity": "1/2 cup", 107 | "name": "basmati rice", 108 | "type": "Misc" 109 | }, 110 | { 111 | "quantity": "1 tsp", 112 | "name": "curry powder", 113 | "type": "Condiments" 114 | }, 115 | { 116 | "quantity": "1 tsp", 117 | "name": "salt", 118 | "type": "Condiments" 119 | } 120 | ], 121 | "steps": [ 122 | "Bring broth to a low boil.", 123 | "Add curry powder and salt.", 124 | "Cook lentils for 20 minutes.", 125 | "Add rice and simmer for 20 minutes.", 126 | "Enjoy!" 127 | ], 128 | "timers": [ 129 | 0, 130 | 0, 131 | 20, 132 | 20, 133 | 0 134 | ], 135 | "imageURL": "http://dagzhsfg97k4.cloudfront.net/wp-content/uploads/2012/05/lentils3.jpg" 136 | }, 137 | { 138 | "name": "Big Night Pizza", 139 | "ingredients": [ 140 | { 141 | "quantity": "5 teaspoons", 142 | "name": "yeast", 143 | "type": "Baking" 144 | }, 145 | { 146 | "quantity": "5 cups", 147 | "name": "flour", 148 | "type": "Baking" 149 | }, 150 | { 151 | "quantity": "4 tablespoons", 152 | "name": "vegetable oil", 153 | "type": "Baking" 154 | }, 155 | { 156 | "quantity": "2 tablespoons", 157 | "name": "sugar", 158 | "type": "Baking" 159 | }, 160 | { 161 | "quantity": "2 teaspoons", 162 | "name": "salt", 163 | "type": "Baking" 164 | }, 165 | { 166 | "quantity": "2 cups", 167 | "name": "hot water", 168 | "type": "Misc" 169 | }, 170 | { 171 | "quantity": "1/4 cup", 172 | "name": "pizza sauce", 173 | "type": "Misc" 174 | }, 175 | { 176 | "quantity": "3/4 cup", 177 | "name": "mozzarella cheese", 178 | "type": "Dairy" 179 | } 180 | ], 181 | "steps": [ 182 | "Add hot water to yeast in a large bowl and let sit for 15 minutes.", 183 | "Mix in oil, sugar, salt, and flour and let sit for 1 hour.", 184 | "Knead the dough and spread onto a pan.", 185 | "Spread pizza sauce and sprinkle cheese.", 186 | "Add any optional toppings as you wish.", 187 | "Bake at 400 deg Fahrenheit for 15 minutes.", 188 | "Enjoy!" 189 | ], 190 | "timers": [ 191 | 15, 192 | 60, 193 | 0, 194 | 0, 195 | 0, 196 | 15, 197 | 0 198 | ], 199 | "imageURL": "http://upload.wikimedia.org/wikipedia/commons/c/c7/Spinach_pizza.jpg" 200 | }, 201 | { 202 | "name": "Cranberry and Apple Stuffed Acorn Squash Recipe", 203 | "ingredients": [ 204 | { 205 | "quantity": "2", 206 | "name": "acorn squash", 207 | "type": "Produce" 208 | }, 209 | { 210 | "quantity": "1", 211 | "name": "boiling water", 212 | "type": "Drinks" 213 | }, 214 | { 215 | "quantity": "2", 216 | "name": "apples chopped into 1.4 inch pieces", 217 | "type": "Produce" 218 | }, 219 | { 220 | "quantity": "1/2 cup", 221 | "name": "dried cranberries", 222 | "type": "Produce" 223 | }, 224 | { 225 | "quantity": "1 teaspoon", 226 | "name": "cinnamon", 227 | "type": "Baking" 228 | }, 229 | { 230 | "quantity": "2 tablespoons", 231 | "name": "melted butter", 232 | "type": "Dairy" 233 | } 234 | ], 235 | "steps": [ 236 | "Cut squash in half, remove seeds.", 237 | "Place squash in baking dish, cut-side down.", 238 | "Pour 1/4-inch water into dish.", 239 | "Bake for 30 minutes at 350 degrees F.", 240 | "In large bowl, combine remaining ingredients.", 241 | "Remove squash from oven, fill with mix.", 242 | "Bake for 30-40 more minutes, until squash tender.", 243 | "Enjoy!" 244 | ], 245 | "timers": [ 246 | 0, 247 | 0, 248 | 0, 249 | 30, 250 | 0, 251 | 0, 252 | 30, 253 | 0 254 | ], 255 | "imageURL": "http://elanaspantry.com/wp-content/uploads/2008/10/acorn_squash_with_cranberry.jpg", 256 | "originalURL": "" 257 | }, 258 | { 259 | "name": "Mic's Yorkshire Puds", 260 | "ingredients": [ 261 | { 262 | "quantity": "200g", 263 | "name": "plain flour", 264 | "type": "Baking" 265 | }, 266 | { 267 | "quantity": "3", 268 | "name": "eggs", 269 | "type": "Dairy" 270 | }, 271 | { 272 | "quantity": "300ml", 273 | "name": "milk", 274 | "type": "Dairy" 275 | }, 276 | { 277 | "quantity": "3 tbsp", 278 | "name": "vegetable oil", 279 | "type": "Condiments" 280 | } 281 | ], 282 | "steps": [ 283 | "Put the flour and some seasoning into a large bowl.", 284 | "Stir in eggs, one at a time.", 285 | "Whisk in milk until you have a smooth batter.", 286 | "Chill in the fridge for at least 30 minutes.", 287 | "Heat oven to 220C/gas mark 7.", 288 | "Pour the oil into the holes of a 8-hole muffin tin.", 289 | "Heat tin in the oven for 5 minutes.", 290 | "Ladle the batter mix into the tin.", 291 | "Bake for 30 minutes until well browned and risen." 292 | ], 293 | "timers": [ 294 | 0, 295 | 0, 296 | 0, 297 | 30, 298 | 0, 299 | 0, 300 | 5, 301 | 0, 302 | 30 303 | ], 304 | "imageURL": "http://upload.wikimedia.org/wikipedia/commons/f/f9/Yorkshire_Pudding.jpg", 305 | "originalURL": "http://upload.wikimedia.org/wikipedia/commons/f/f9/Yorkshire_Pudding.jpg" 306 | }, 307 | { 308 | "name": "Old-Fashioned Oatmeal Cookies", 309 | "ingredients": [ 310 | { 311 | "quantity": "1 cup", 312 | "name": "raisins", 313 | "type": "Produce" 314 | }, 315 | { 316 | "quantity": "1", 317 | "name": "cup water", 318 | "type": "Drinks" 319 | }, 320 | { 321 | "quantity": "3/4 cup", 322 | "name": "shortening", 323 | "type": "Baking" 324 | }, 325 | { 326 | "quantity": "1 1/2 cups", 327 | "name": "sugar", 328 | "type": "Baking" 329 | }, 330 | { 331 | "quantity": "2 1/2 cups", 332 | "name": "flour", 333 | "type": "Baking" 334 | }, 335 | { 336 | "quantity": "1 tsp.", 337 | "name": "soda", 338 | "type": "Baking" 339 | }, 340 | { 341 | "quantity": "1 tsp.", 342 | "name": "salt", 343 | "type": "Baking" 344 | }, 345 | { 346 | "quantity": "1 tsp.", 347 | "name": "cinnamon", 348 | "type": "Baking" 349 | }, 350 | { 351 | "quantity": "1/2 tsp.", 352 | "name": "baking powder", 353 | "type": "Baking" 354 | }, 355 | { 356 | "quantity": "1/2 tsp.", 357 | "name": "cloves", 358 | "type": "Baking" 359 | }, 360 | { 361 | "quantity": "2 cups", 362 | "name": "oats", 363 | "type": "Baking" 364 | }, 365 | { 366 | "quantity": "1/2 cup", 367 | "name": "chopped nuts", 368 | "type": "Baking" 369 | } 370 | ], 371 | "steps": [ 372 | "Simmer raisins and water over medium heat until raisins are plump, about 15 minutes.", 373 | "Drain raisins, reserving the liquid.", 374 | "Add enough water to reserved liquid to measure 1/2 cup.", 375 | "Heat oven to 400°.", 376 | "Mix thoroughly shortening, sugar, eggs and vanilla.", 377 | "Stir in reserved liquid.", 378 | "Blend in remaining ingredients.", 379 | "Drop dough by rounded teaspoonfuls about 2 inches apart onto ungreased baking sheet.", 380 | "Bake 8 to 10 minutes or until light brown.", 381 | "About 6 1/2 dozen cookies." 382 | ], 383 | "timers": [ 384 | 15, 385 | 0, 386 | 0, 387 | 0, 388 | 0, 389 | 0, 390 | 0, 391 | 0, 392 | 8, 393 | 0 394 | ], 395 | "imageURL": "http://s3.amazonaws.com/gmi-digital-library/65caecf7-a8f7-4a09-8513-2659cf92871e.jpg", 396 | "originalURL": "#" 397 | }, 398 | { 399 | "name": "Blueberry Oatmeal Squares", 400 | "ingredients": [ 401 | { 402 | "quantity": "2-1/2 cups", 403 | "name": "rolled oats, (not instant)", 404 | "type": "Baking" 405 | }, 406 | { 407 | "quantity": "1-1/4 cups", 408 | "name": "all-purpose flour", 409 | "type": "Baking" 410 | }, 411 | { 412 | "quantity": "1 tbsp", 413 | "name": "grated orange rind", 414 | "type": "Produce" 415 | }, 416 | { 417 | "quantity": "1/4 tsp", 418 | "name": "salt", 419 | "type": "Baking" 420 | }, 421 | { 422 | "quantity": "1 cup", 423 | "name": "cold butter, cubed", 424 | "type": "Baking" 425 | }, 426 | { 427 | "quantity": "3/4 cup", 428 | "name": "packed brown sugar", 429 | "type": "Baking" 430 | }, 431 | { 432 | "quantity": "3 cups", 433 | "name": "fresh blueberries", 434 | "type": "Produce" 435 | }, 436 | { 437 | "quantity": "1/2 cup", 438 | "name": "granulated sugar", 439 | "type": "Baking" 440 | }, 441 | { 442 | "quantity": "1/3 cup", 443 | "name": "orange juice", 444 | "type": "Produce" 445 | }, 446 | { 447 | "quantity": "4 tsp", 448 | "name": "cornstarch", 449 | "type": "Baking" 450 | } 451 | ], 452 | "steps": [ 453 | "Filling: In saucepan, bring blueberries, sugar and orange juice to boil; reduce heat and simmer until tender, about 10 minutes.", 454 | "Whisk cornstarch with 2 tbsp (25 mL) water; whisk into blueberries and boil, stirring, until thickened, about 1 minute.", 455 | "Place plastic wrap directly on surface; refrigerate until cooled, about 1 hour.", 456 | "In large bowl, whisk together oats, flour, sugar, orange rind and salt ; with pastry blender, cut in butter until in coarse crumbs.", 457 | "Press half into 8-inch (2 L) square parchment paper–lined metal cake pan; spread with blueberry filling.", 458 | "Bake in centre of 350°F oven until light golden, about 45 minutes.", 459 | "Let cool on rack before cutting into squares.", 460 | "(Make-ahead: Cover and refrigerate for up to 2 days or overwrap with heavy-duty foil and freeze for up to 2 weeks.)" 461 | ], 462 | "timers": [ 463 | 10, 464 | 1, 465 | 60, 466 | 0, 467 | 0, 468 | 45, 469 | 0, 470 | 0 471 | ], 472 | "imageURL": "http://www.canadianliving.com/img/photos/biz/blueberry-oatmeal-squares5801359401371.jpg", 473 | "originalURL": "http://www.canadianliving.com/food/blueberry_oatmeal_squares.php" 474 | }, 475 | { 476 | "name": "Curried chicken salad", 477 | "ingredients": [ 478 | { 479 | "quantity": "3", 480 | "name": "skinless, boneless chicken breasts, halved lengthwise", 481 | "type": "Meat" 482 | }, 483 | { 484 | "quantity": "1/2 cup", 485 | "name": "mayonnaise", 486 | "type": "Baking" 487 | }, 488 | { 489 | "quantity": "1 tbsp", 490 | "name": "lemon zest", 491 | "type": "Produce" 492 | }, 493 | { 494 | "quantity": "1 tbsp ", 495 | "name": "lemon juice", 496 | "type": "Produce" 497 | }, 498 | { 499 | "quantity": "1 1/2 tsp", 500 | "name": "curry powder", 501 | "type": "Baking" 502 | }, 503 | { 504 | "quantity": "1/4 tsp", 505 | "name": "salt", 506 | "type": "Baking" 507 | }, 508 | { 509 | "quantity": "2", 510 | "name": "ripe mangoes, diced", 511 | "type": "Produce" 512 | }, 513 | { 514 | "quantity": "1/4 cup", 515 | "name": "dried cranberries", 516 | "type": "Produce" 517 | }, 518 | { 519 | "quantity": "2", 520 | "name": "green onions, thinly sliced", 521 | "type": "Produce" 522 | }, 523 | { 524 | "quantity": "1", 525 | "name": "celery stalk, finely chopped", 526 | "type": "Produce" 527 | }, 528 | { 529 | "quantity": "6 leaves", 530 | "name": "Boston lettuce", 531 | "type": "Produce" 532 | }, 533 | { 534 | "quantity": "6", 535 | "name": "English muffins, toasted", 536 | "type": "Misc" 537 | } 538 | ], 539 | "steps": [ 540 | "ARRANGE chicken in a single layer in a large pot.", 541 | "Add water to just cover.", 542 | "Bring to a boil over medium-high.", 543 | "Flip chicken, reduce heat to medium and simmer until cooked, about 6 more min.", 544 | "Cool.", 545 | "STIR mayo with lemon zest, juice, curry and salt in large bowl.", 546 | "Using 2 forks, shred chicken, then stir into mayo mixture with mango, cranberries, green onions and celery.", 547 | "Divide among muffins with lettuce leaves", 548 | "Sandwich with tops" 549 | ], 550 | "timers": [ 551 | 0, 552 | 0, 553 | 0, 554 | 6, 555 | 0, 556 | 0, 557 | 0, 558 | 0, 559 | 0 560 | ], 561 | "imageURL": "http://www.chatelaine.com/wp-content/uploads/2013/05/Curried-chicken-salad.jpg", 562 | "originalURL": "http://www.chatelaine.com/recipe/stovetop-cooking-method/curried-chicken-salad/" 563 | } 564 | ] 565 | -------------------------------------------------------------------------------- /RecipesApp.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 50; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 625B74D92177B9A30086E605 /* ProductListPresenterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625B74D82177B9A30086E605 /* ProductListPresenterTests.swift */; }; 11 | 625B74DB2177BC010086E605 /* FetchDataInteractorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625B74DA2177BC010086E605 /* FetchDataInteractorTests.swift */; }; 12 | 625B74DF2177C0540086E605 /* RecipesCommandTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625B74DE2177C0540086E605 /* RecipesCommandTests.swift */; }; 13 | 625B74E1217934760086E605 /* ProductListCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625B74E0217934760086E605 /* ProductListCell.swift */; }; 14 | 625B74EC21794D1F0086E605 /* ProductPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625B74EB21794D1F0086E605 /* ProductPageViewController.swift */; }; 15 | 625B74EF217A9FA40086E605 /* ProductPageRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625B74EE217A9FA40086E605 /* ProductPageRouter.swift */; }; 16 | 625B74F1217A9FAC0086E605 /* ProductPagePresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625B74F0217A9FAC0086E605 /* ProductPagePresenter.swift */; }; 17 | 625B74F3217A9FC00086E605 /* ProductPageProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625B74F2217A9FC00086E605 /* ProductPageProtocol.swift */; }; 18 | 625B74F5217AA2D90086E605 /* ProductPagePresenterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625B74F4217AA2D90086E605 /* ProductPagePresenterTests.swift */; }; 19 | 625B74F7217AA31E0086E605 /* MockedProductPageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625B74F6217AA31E0086E605 /* MockedProductPageView.swift */; }; 20 | 62748BCF2175084B000B403B /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62748BCE2175084B000B403B /* AppDelegate.swift */; }; 21 | 62748BD42175084B000B403B /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 62748BD22175084B000B403B /* Main.storyboard */; }; 22 | 62748BD62175084C000B403B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 62748BD52175084C000B403B /* Assets.xcassets */; }; 23 | 62748BD92175084C000B403B /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 62748BD72175084C000B403B /* LaunchScreen.storyboard */; }; 24 | 62748BE42175084C000B403B /* RecipesResponseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62748BE32175084C000B403B /* RecipesResponseTests.swift */; }; 25 | 62748BF321752A28000B403B /* Ingredient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62748BF221752A28000B403B /* Ingredient.swift */; }; 26 | 62748BF521752AA3000B403B /* Recipe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62748BF421752AA3000B403B /* Recipe.swift */; }; 27 | 62748BF821752B74000B403B /* Recipes.json in Resources */ = {isa = PBXBuildFile; fileRef = 62748BF721752B74000B403B /* Recipes.json */; }; 28 | 62748BFB21752CF3000B403B /* JSONUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62748BFA21752CF3000B403B /* JSONUtil.swift */; }; 29 | 62748BFD217530A1000B403B /* Recipe.json in Resources */ = {isa = PBXBuildFile; fileRef = 62748BFC217530A1000B403B /* Recipe.json */; }; 30 | 62748C092175376A000B403B /* Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62748C0021753769000B403B /* Utils.swift */; }; 31 | 62748C0A2175376A000B403B /* Session.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62748C0121753769000B403B /* Session.swift */; }; 32 | 62748C0B2175376A000B403B /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62748C0221753769000B403B /* Constants.swift */; }; 33 | 62748C0C2175376A000B403B /* Service.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62748C0321753769000B403B /* Service.swift */; }; 34 | 62748C0D2175376A000B403B /* Dispatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62748C0421753769000B403B /* Dispatcher.swift */; }; 35 | 62748C0E2175376A000B403B /* Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62748C0521753769000B403B /* Configuration.swift */; }; 36 | 62748C0F2175376A000B403B /* ServiceFacade.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62748C0621753769000B403B /* ServiceFacade.swift */; }; 37 | 62748C102175376A000B403B /* ServiceError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62748C072175376A000B403B /* ServiceError.swift */; }; 38 | 62748C1321753E0E000B403B /* RecipesCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62748C1221753E0E000B403B /* RecipesCommand.swift */; }; 39 | 62748C1821754036000B403B /* MockedSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62748C1721754036000B403B /* MockedSession.swift */; }; 40 | 62748C1E2176A14E000B403B /* ProductListRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62748C1D2176A14E000B403B /* ProductListRouter.swift */; }; 41 | 62748C202176A15D000B403B /* ProductListPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62748C1F2176A15D000B403B /* ProductListPresenter.swift */; }; 42 | 62748C222176A17A000B403B /* ProductListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62748C212176A17A000B403B /* ProductListViewController.swift */; }; 43 | 62748C242176A37F000B403B /* ProductListProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62748C232176A37F000B403B /* ProductListProtocols.swift */; }; 44 | 62748C262176A69B000B403B /* FetchDataInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62748C252176A69B000B403B /* FetchDataInteractor.swift */; }; 45 | 62748C2D21779A54000B403B /* MockedProductListPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62748C2921779A0A000B403B /* MockedProductListPresenter.swift */; }; 46 | 62748C2E21779A57000B403B /* MockedProductListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62748C2B21779A16000B403B /* MockedProductListView.swift */; }; 47 | 62748C3321779BDE000B403B /* MockedFetchDataInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62748C2F21779B7A000B403B /* MockedFetchDataInteractor.swift */; }; 48 | 6283DA05217C63720008E0E0 /* FilterDataInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6283DA04217C63720008E0E0 /* FilterDataInteractor.swift */; }; 49 | 6283DA0D217C863A0008E0E0 /* MockedFilterDataInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6283DA09217C855D0008E0E0 /* MockedFilterDataInteractor.swift */; }; 50 | 6283DA0F217C8FCB0008E0E0 /* FilterDataInteractorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6283DA0E217C8FCB0008E0E0 /* FilterDataInteractorTests.swift */; }; 51 | 62FACB91217E765F00A5FAEA /* DownloadImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62FACB90217E765F00A5FAEA /* DownloadImage.swift */; }; 52 | 62FACB93217E7D0800A5FAEA /* ImageFacade.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62FACB92217E7D0800A5FAEA /* ImageFacade.swift */; }; 53 | 62FACB95217EA1F200A5FAEA /* CacheFacade.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62FACB94217EA1F200A5FAEA /* CacheFacade.swift */; }; 54 | 62FACB97217FBD2300A5FAEA /* CacheFacadeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62FACB96217FBD2300A5FAEA /* CacheFacadeTests.swift */; }; 55 | 62FACB9B217FD1F400A5FAEA /* UtilsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62FACB9A217FD1F400A5FAEA /* UtilsTests.swift */; }; 56 | /* End PBXBuildFile section */ 57 | 58 | /* Begin PBXContainerItemProxy section */ 59 | 62748BE02175084C000B403B /* PBXContainerItemProxy */ = { 60 | isa = PBXContainerItemProxy; 61 | containerPortal = 62748BC32175084B000B403B /* Project object */; 62 | proxyType = 1; 63 | remoteGlobalIDString = 62748BCA2175084B000B403B; 64 | remoteInfo = Recipes; 65 | }; 66 | /* End PBXContainerItemProxy section */ 67 | 68 | /* Begin PBXFileReference section */ 69 | 625B74D82177B9A30086E605 /* ProductListPresenterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductListPresenterTests.swift; sourceTree = ""; }; 70 | 625B74DA2177BC010086E605 /* FetchDataInteractorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchDataInteractorTests.swift; sourceTree = ""; }; 71 | 625B74DE2177C0540086E605 /* RecipesCommandTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipesCommandTests.swift; sourceTree = ""; }; 72 | 625B74E0217934760086E605 /* ProductListCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductListCell.swift; sourceTree = ""; }; 73 | 625B74EB21794D1F0086E605 /* ProductPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductPageViewController.swift; sourceTree = ""; }; 74 | 625B74EE217A9FA40086E605 /* ProductPageRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductPageRouter.swift; sourceTree = ""; }; 75 | 625B74F0217A9FAC0086E605 /* ProductPagePresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductPagePresenter.swift; sourceTree = ""; }; 76 | 625B74F2217A9FC00086E605 /* ProductPageProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductPageProtocol.swift; sourceTree = ""; }; 77 | 625B74F4217AA2D90086E605 /* ProductPagePresenterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductPagePresenterTests.swift; sourceTree = ""; }; 78 | 625B74F6217AA31E0086E605 /* MockedProductPageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockedProductPageView.swift; sourceTree = ""; }; 79 | 62748BCB2175084B000B403B /* RecipesApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = RecipesApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; 80 | 62748BCE2175084B000B403B /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 81 | 62748BD32175084B000B403B /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 82 | 62748BD52175084C000B403B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 83 | 62748BD82175084C000B403B /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 84 | 62748BDA2175084C000B403B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 85 | 62748BDF2175084C000B403B /* RecipesAppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RecipesAppTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 86 | 62748BE32175084C000B403B /* RecipesResponseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipesResponseTests.swift; sourceTree = ""; }; 87 | 62748BE52175084C000B403B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 88 | 62748BF221752A28000B403B /* Ingredient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ingredient.swift; sourceTree = ""; }; 89 | 62748BF421752AA3000B403B /* Recipe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Recipe.swift; sourceTree = ""; }; 90 | 62748BF721752B74000B403B /* Recipes.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = Recipes.json; sourceTree = ""; }; 91 | 62748BFA21752CF3000B403B /* JSONUtil.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JSONUtil.swift; sourceTree = ""; }; 92 | 62748BFC217530A1000B403B /* Recipe.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = Recipe.json; sourceTree = ""; }; 93 | 62748C0021753769000B403B /* Utils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Utils.swift; sourceTree = ""; }; 94 | 62748C0121753769000B403B /* Session.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Session.swift; sourceTree = ""; }; 95 | 62748C0221753769000B403B /* Constants.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; 96 | 62748C0321753769000B403B /* Service.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Service.swift; sourceTree = ""; }; 97 | 62748C0421753769000B403B /* Dispatcher.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Dispatcher.swift; sourceTree = ""; }; 98 | 62748C0521753769000B403B /* Configuration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Configuration.swift; sourceTree = ""; }; 99 | 62748C0621753769000B403B /* ServiceFacade.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ServiceFacade.swift; sourceTree = ""; }; 100 | 62748C072175376A000B403B /* ServiceError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ServiceError.swift; sourceTree = ""; }; 101 | 62748C1221753E0E000B403B /* RecipesCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipesCommand.swift; sourceTree = ""; }; 102 | 62748C1721754036000B403B /* MockedSession.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockedSession.swift; sourceTree = ""; }; 103 | 62748C1D2176A14E000B403B /* ProductListRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductListRouter.swift; sourceTree = ""; }; 104 | 62748C1F2176A15D000B403B /* ProductListPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductListPresenter.swift; sourceTree = ""; }; 105 | 62748C212176A17A000B403B /* ProductListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductListViewController.swift; sourceTree = ""; }; 106 | 62748C232176A37F000B403B /* ProductListProtocols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductListProtocols.swift; sourceTree = ""; }; 107 | 62748C252176A69B000B403B /* FetchDataInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchDataInteractor.swift; sourceTree = ""; }; 108 | 62748C2921779A0A000B403B /* MockedProductListPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockedProductListPresenter.swift; sourceTree = ""; }; 109 | 62748C2B21779A16000B403B /* MockedProductListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockedProductListView.swift; sourceTree = ""; }; 110 | 62748C2F21779B7A000B403B /* MockedFetchDataInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockedFetchDataInteractor.swift; sourceTree = ""; }; 111 | 6283DA04217C63720008E0E0 /* FilterDataInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterDataInteractor.swift; sourceTree = ""; }; 112 | 6283DA09217C855D0008E0E0 /* MockedFilterDataInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockedFilterDataInteractor.swift; sourceTree = ""; }; 113 | 6283DA0E217C8FCB0008E0E0 /* FilterDataInteractorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterDataInteractorTests.swift; sourceTree = ""; }; 114 | 62FACB90217E765F00A5FAEA /* DownloadImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadImage.swift; sourceTree = ""; }; 115 | 62FACB92217E7D0800A5FAEA /* ImageFacade.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageFacade.swift; sourceTree = ""; }; 116 | 62FACB94217EA1F200A5FAEA /* CacheFacade.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheFacade.swift; sourceTree = ""; }; 117 | 62FACB96217FBD2300A5FAEA /* CacheFacadeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheFacadeTests.swift; sourceTree = ""; }; 118 | 62FACB9A217FD1F400A5FAEA /* UtilsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UtilsTests.swift; sourceTree = ""; }; 119 | /* End PBXFileReference section */ 120 | 121 | /* Begin PBXFrameworksBuildPhase section */ 122 | 62748BC82175084B000B403B /* Frameworks */ = { 123 | isa = PBXFrameworksBuildPhase; 124 | buildActionMask = 2147483647; 125 | files = ( 126 | ); 127 | runOnlyForDeploymentPostprocessing = 0; 128 | }; 129 | 62748BDC2175084C000B403B /* Frameworks */ = { 130 | isa = PBXFrameworksBuildPhase; 131 | buildActionMask = 2147483647; 132 | files = ( 133 | ); 134 | runOnlyForDeploymentPostprocessing = 0; 135 | }; 136 | /* End PBXFrameworksBuildPhase section */ 137 | 138 | /* Begin PBXGroup section */ 139 | 625B74ED217953300086E605 /* ProductPage */ = { 140 | isa = PBXGroup; 141 | children = ( 142 | 625B74F2217A9FC00086E605 /* ProductPageProtocol.swift */, 143 | 625B74EE217A9FA40086E605 /* ProductPageRouter.swift */, 144 | 625B74F0217A9FAC0086E605 /* ProductPagePresenter.swift */, 145 | 625B74EB21794D1F0086E605 /* ProductPageViewController.swift */, 146 | ); 147 | path = ProductPage; 148 | sourceTree = ""; 149 | }; 150 | 62748BC22175084B000B403B = { 151 | isa = PBXGroup; 152 | children = ( 153 | 62748BCD2175084B000B403B /* Recipes */, 154 | 62748BE22175084C000B403B /* RecipesTests */, 155 | 62748BCC2175084B000B403B /* Products */, 156 | ); 157 | sourceTree = ""; 158 | }; 159 | 62748BCC2175084B000B403B /* Products */ = { 160 | isa = PBXGroup; 161 | children = ( 162 | 62748BCB2175084B000B403B /* RecipesApp.app */, 163 | 62748BDF2175084C000B403B /* RecipesAppTests.xctest */, 164 | ); 165 | name = Products; 166 | sourceTree = ""; 167 | }; 168 | 62748BCD2175084B000B403B /* Recipes */ = { 169 | isa = PBXGroup; 170 | children = ( 171 | 62748BCE2175084B000B403B /* AppDelegate.swift */, 172 | 62748C0221753769000B403B /* Constants.swift */, 173 | 62748BFE2175372F000B403B /* Core */, 174 | 62748C1C2176A132000B403B /* Product List */, 175 | 625B74ED217953300086E605 /* ProductPage */, 176 | 62748BD22175084B000B403B /* Main.storyboard */, 177 | 62748BD52175084C000B403B /* Assets.xcassets */, 178 | 62748BD72175084C000B403B /* LaunchScreen.storyboard */, 179 | 62748BDA2175084C000B403B /* Info.plist */, 180 | ); 181 | path = Recipes; 182 | sourceTree = ""; 183 | }; 184 | 62748BE22175084C000B403B /* RecipesTests */ = { 185 | isa = PBXGroup; 186 | children = ( 187 | 62748BFA21752CF3000B403B /* JSONUtil.swift */, 188 | 62748BF621752B49000B403B /* Mocks */, 189 | 62748BE32175084C000B403B /* RecipesResponseTests.swift */, 190 | 625B74DE2177C0540086E605 /* RecipesCommandTests.swift */, 191 | 625B74D82177B9A30086E605 /* ProductListPresenterTests.swift */, 192 | 625B74DA2177BC010086E605 /* FetchDataInteractorTests.swift */, 193 | 6283DA0E217C8FCB0008E0E0 /* FilterDataInteractorTests.swift */, 194 | 625B74F4217AA2D90086E605 /* ProductPagePresenterTests.swift */, 195 | 62FACB96217FBD2300A5FAEA /* CacheFacadeTests.swift */, 196 | 62FACB9A217FD1F400A5FAEA /* UtilsTests.swift */, 197 | 62748BE52175084C000B403B /* Info.plist */, 198 | ); 199 | path = RecipesTests; 200 | sourceTree = ""; 201 | }; 202 | 62748BF121752A1A000B403B /* Models */ = { 203 | isa = PBXGroup; 204 | children = ( 205 | 62748BF221752A28000B403B /* Ingredient.swift */, 206 | 62748BF421752AA3000B403B /* Recipe.swift */, 207 | ); 208 | path = Models; 209 | sourceTree = ""; 210 | }; 211 | 62748BF621752B49000B403B /* Mocks */ = { 212 | isa = PBXGroup; 213 | children = ( 214 | 62748C1721754036000B403B /* MockedSession.swift */, 215 | 62748BF721752B74000B403B /* Recipes.json */, 216 | 62748BFC217530A1000B403B /* Recipe.json */, 217 | 62748C2921779A0A000B403B /* MockedProductListPresenter.swift */, 218 | 62748C2B21779A16000B403B /* MockedProductListView.swift */, 219 | 62748C2F21779B7A000B403B /* MockedFetchDataInteractor.swift */, 220 | 6283DA09217C855D0008E0E0 /* MockedFilterDataInteractor.swift */, 221 | 625B74F6217AA31E0086E605 /* MockedProductPageView.swift */, 222 | ); 223 | path = Mocks; 224 | sourceTree = ""; 225 | }; 226 | 62748BFE2175372F000B403B /* Core */ = { 227 | isa = PBXGroup; 228 | children = ( 229 | 62748BF121752A1A000B403B /* Models */, 230 | 62748C1421753E5B000B403B /* Commands */, 231 | 62748C1121753798000B403B /* Common */, 232 | 62748C0621753769000B403B /* ServiceFacade.swift */, 233 | 62FACB92217E7D0800A5FAEA /* ImageFacade.swift */, 234 | 62FACB94217EA1F200A5FAEA /* CacheFacade.swift */, 235 | 62748C0521753769000B403B /* Configuration.swift */, 236 | 62748C0321753769000B403B /* Service.swift */, 237 | 62748C0121753769000B403B /* Session.swift */, 238 | 62748C072175376A000B403B /* ServiceError.swift */, 239 | ); 240 | path = Core; 241 | sourceTree = ""; 242 | }; 243 | 62748C1121753798000B403B /* Common */ = { 244 | isa = PBXGroup; 245 | children = ( 246 | 62748C0421753769000B403B /* Dispatcher.swift */, 247 | 62748C0021753769000B403B /* Utils.swift */, 248 | ); 249 | path = Common; 250 | sourceTree = ""; 251 | }; 252 | 62748C1421753E5B000B403B /* Commands */ = { 253 | isa = PBXGroup; 254 | children = ( 255 | 62748C1221753E0E000B403B /* RecipesCommand.swift */, 256 | 62FACB90217E765F00A5FAEA /* DownloadImage.swift */, 257 | ); 258 | path = Commands; 259 | sourceTree = ""; 260 | }; 261 | 62748C1C2176A132000B403B /* Product List */ = { 262 | isa = PBXGroup; 263 | children = ( 264 | 6283DA08217C64940008E0E0 /* Interactors */, 265 | 62748C232176A37F000B403B /* ProductListProtocols.swift */, 266 | 62748C1D2176A14E000B403B /* ProductListRouter.swift */, 267 | 62748C1F2176A15D000B403B /* ProductListPresenter.swift */, 268 | 62748C212176A17A000B403B /* ProductListViewController.swift */, 269 | 625B74E0217934760086E605 /* ProductListCell.swift */, 270 | ); 271 | path = "Product List"; 272 | sourceTree = ""; 273 | }; 274 | 6283DA08217C64940008E0E0 /* Interactors */ = { 275 | isa = PBXGroup; 276 | children = ( 277 | 62748C252176A69B000B403B /* FetchDataInteractor.swift */, 278 | 6283DA04217C63720008E0E0 /* FilterDataInteractor.swift */, 279 | ); 280 | path = Interactors; 281 | sourceTree = ""; 282 | }; 283 | /* End PBXGroup section */ 284 | 285 | /* Begin PBXNativeTarget section */ 286 | 62748BCA2175084B000B403B /* RecipesApp */ = { 287 | isa = PBXNativeTarget; 288 | buildConfigurationList = 62748BE82175084C000B403B /* Build configuration list for PBXNativeTarget "RecipesApp" */; 289 | buildPhases = ( 290 | 62748BC72175084B000B403B /* Sources */, 291 | 62748BC82175084B000B403B /* Frameworks */, 292 | 62748BC92175084B000B403B /* Resources */, 293 | ); 294 | buildRules = ( 295 | ); 296 | dependencies = ( 297 | ); 298 | name = RecipesApp; 299 | productName = Recipes; 300 | productReference = 62748BCB2175084B000B403B /* RecipesApp.app */; 301 | productType = "com.apple.product-type.application"; 302 | }; 303 | 62748BDE2175084C000B403B /* RecipesAppTests */ = { 304 | isa = PBXNativeTarget; 305 | buildConfigurationList = 62748BEB2175084C000B403B /* Build configuration list for PBXNativeTarget "RecipesAppTests" */; 306 | buildPhases = ( 307 | 62748BDB2175084C000B403B /* Sources */, 308 | 62748BDC2175084C000B403B /* Frameworks */, 309 | 62748BDD2175084C000B403B /* Resources */, 310 | ); 311 | buildRules = ( 312 | ); 313 | dependencies = ( 314 | 62748BE12175084C000B403B /* PBXTargetDependency */, 315 | ); 316 | name = RecipesAppTests; 317 | productName = RecipesTests; 318 | productReference = 62748BDF2175084C000B403B /* RecipesAppTests.xctest */; 319 | productType = "com.apple.product-type.bundle.unit-test"; 320 | }; 321 | /* End PBXNativeTarget section */ 322 | 323 | /* Begin PBXProject section */ 324 | 62748BC32175084B000B403B /* Project object */ = { 325 | isa = PBXProject; 326 | attributes = { 327 | LastSwiftUpdateCheck = 1000; 328 | LastUpgradeCheck = 1000; 329 | ORGANIZATIONNAME = "Mattia Cantalù"; 330 | TargetAttributes = { 331 | 62748BCA2175084B000B403B = { 332 | CreatedOnToolsVersion = 10.0; 333 | }; 334 | 62748BDE2175084C000B403B = { 335 | CreatedOnToolsVersion = 10.0; 336 | TestTargetID = 62748BCA2175084B000B403B; 337 | }; 338 | }; 339 | }; 340 | buildConfigurationList = 62748BC62175084B000B403B /* Build configuration list for PBXProject "RecipesApp" */; 341 | compatibilityVersion = "Xcode 9.3"; 342 | developmentRegion = en; 343 | hasScannedForEncodings = 0; 344 | knownRegions = ( 345 | en, 346 | Base, 347 | ); 348 | mainGroup = 62748BC22175084B000B403B; 349 | productRefGroup = 62748BCC2175084B000B403B /* Products */; 350 | projectDirPath = ""; 351 | projectRoot = ""; 352 | targets = ( 353 | 62748BCA2175084B000B403B /* RecipesApp */, 354 | 62748BDE2175084C000B403B /* RecipesAppTests */, 355 | ); 356 | }; 357 | /* End PBXProject section */ 358 | 359 | /* Begin PBXResourcesBuildPhase section */ 360 | 62748BC92175084B000B403B /* Resources */ = { 361 | isa = PBXResourcesBuildPhase; 362 | buildActionMask = 2147483647; 363 | files = ( 364 | 62748BD92175084C000B403B /* LaunchScreen.storyboard in Resources */, 365 | 62748BD62175084C000B403B /* Assets.xcassets in Resources */, 366 | 62748BD42175084B000B403B /* Main.storyboard in Resources */, 367 | ); 368 | runOnlyForDeploymentPostprocessing = 0; 369 | }; 370 | 62748BDD2175084C000B403B /* Resources */ = { 371 | isa = PBXResourcesBuildPhase; 372 | buildActionMask = 2147483647; 373 | files = ( 374 | 62748BF821752B74000B403B /* Recipes.json in Resources */, 375 | 62748BFD217530A1000B403B /* Recipe.json in Resources */, 376 | ); 377 | runOnlyForDeploymentPostprocessing = 0; 378 | }; 379 | /* End PBXResourcesBuildPhase section */ 380 | 381 | /* Begin PBXSourcesBuildPhase section */ 382 | 62748BC72175084B000B403B /* Sources */ = { 383 | isa = PBXSourcesBuildPhase; 384 | buildActionMask = 2147483647; 385 | files = ( 386 | 62748C242176A37F000B403B /* ProductListProtocols.swift in Sources */, 387 | 62FACB91217E765F00A5FAEA /* DownloadImage.swift in Sources */, 388 | 6283DA05217C63720008E0E0 /* FilterDataInteractor.swift in Sources */, 389 | 625B74EC21794D1F0086E605 /* ProductPageViewController.swift in Sources */, 390 | 62748C0A2175376A000B403B /* Session.swift in Sources */, 391 | 62748BCF2175084B000B403B /* AppDelegate.swift in Sources */, 392 | 62748BF321752A28000B403B /* Ingredient.swift in Sources */, 393 | 62748C0D2175376A000B403B /* Dispatcher.swift in Sources */, 394 | 625B74E1217934760086E605 /* ProductListCell.swift in Sources */, 395 | 62748C0F2175376A000B403B /* ServiceFacade.swift in Sources */, 396 | 62748C222176A17A000B403B /* ProductListViewController.swift in Sources */, 397 | 625B74F1217A9FAC0086E605 /* ProductPagePresenter.swift in Sources */, 398 | 625B74EF217A9FA40086E605 /* ProductPageRouter.swift in Sources */, 399 | 62748C0C2175376A000B403B /* Service.swift in Sources */, 400 | 62748BF521752AA3000B403B /* Recipe.swift in Sources */, 401 | 62748C1E2176A14E000B403B /* ProductListRouter.swift in Sources */, 402 | 62FACB95217EA1F200A5FAEA /* CacheFacade.swift in Sources */, 403 | 62748C092175376A000B403B /* Utils.swift in Sources */, 404 | 62748C202176A15D000B403B /* ProductListPresenter.swift in Sources */, 405 | 62748C0B2175376A000B403B /* Constants.swift in Sources */, 406 | 62748C1321753E0E000B403B /* RecipesCommand.swift in Sources */, 407 | 62748C0E2175376A000B403B /* Configuration.swift in Sources */, 408 | 62FACB93217E7D0800A5FAEA /* ImageFacade.swift in Sources */, 409 | 62748C102175376A000B403B /* ServiceError.swift in Sources */, 410 | 625B74F3217A9FC00086E605 /* ProductPageProtocol.swift in Sources */, 411 | 62748C262176A69B000B403B /* FetchDataInteractor.swift in Sources */, 412 | ); 413 | runOnlyForDeploymentPostprocessing = 0; 414 | }; 415 | 62748BDB2175084C000B403B /* Sources */ = { 416 | isa = PBXSourcesBuildPhase; 417 | buildActionMask = 2147483647; 418 | files = ( 419 | 625B74F5217AA2D90086E605 /* ProductPagePresenterTests.swift in Sources */, 420 | 6283DA0F217C8FCB0008E0E0 /* FilterDataInteractorTests.swift in Sources */, 421 | 625B74DB2177BC010086E605 /* FetchDataInteractorTests.swift in Sources */, 422 | 62748C1821754036000B403B /* MockedSession.swift in Sources */, 423 | 625B74D92177B9A30086E605 /* ProductListPresenterTests.swift in Sources */, 424 | 625B74F7217AA31E0086E605 /* MockedProductPageView.swift in Sources */, 425 | 62748C2D21779A54000B403B /* MockedProductListPresenter.swift in Sources */, 426 | 62748BE42175084C000B403B /* RecipesResponseTests.swift in Sources */, 427 | 62FACB9B217FD1F400A5FAEA /* UtilsTests.swift in Sources */, 428 | 625B74DF2177C0540086E605 /* RecipesCommandTests.swift in Sources */, 429 | 62748BFB21752CF3000B403B /* JSONUtil.swift in Sources */, 430 | 62748C3321779BDE000B403B /* MockedFetchDataInteractor.swift in Sources */, 431 | 6283DA0D217C863A0008E0E0 /* MockedFilterDataInteractor.swift in Sources */, 432 | 62FACB97217FBD2300A5FAEA /* CacheFacadeTests.swift in Sources */, 433 | 62748C2E21779A57000B403B /* MockedProductListView.swift in Sources */, 434 | ); 435 | runOnlyForDeploymentPostprocessing = 0; 436 | }; 437 | /* End PBXSourcesBuildPhase section */ 438 | 439 | /* Begin PBXTargetDependency section */ 440 | 62748BE12175084C000B403B /* PBXTargetDependency */ = { 441 | isa = PBXTargetDependency; 442 | target = 62748BCA2175084B000B403B /* RecipesApp */; 443 | targetProxy = 62748BE02175084C000B403B /* PBXContainerItemProxy */; 444 | }; 445 | /* End PBXTargetDependency section */ 446 | 447 | /* Begin PBXVariantGroup section */ 448 | 62748BD22175084B000B403B /* Main.storyboard */ = { 449 | isa = PBXVariantGroup; 450 | children = ( 451 | 62748BD32175084B000B403B /* Base */, 452 | ); 453 | name = Main.storyboard; 454 | sourceTree = ""; 455 | }; 456 | 62748BD72175084C000B403B /* LaunchScreen.storyboard */ = { 457 | isa = PBXVariantGroup; 458 | children = ( 459 | 62748BD82175084C000B403B /* Base */, 460 | ); 461 | name = LaunchScreen.storyboard; 462 | sourceTree = ""; 463 | }; 464 | /* End PBXVariantGroup section */ 465 | 466 | /* Begin XCBuildConfiguration section */ 467 | 62748BE62175084C000B403B /* Debug */ = { 468 | isa = XCBuildConfiguration; 469 | buildSettings = { 470 | ALWAYS_SEARCH_USER_PATHS = NO; 471 | CLANG_ANALYZER_NONNULL = YES; 472 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 473 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 474 | CLANG_CXX_LIBRARY = "libc++"; 475 | CLANG_ENABLE_MODULES = YES; 476 | CLANG_ENABLE_OBJC_ARC = YES; 477 | CLANG_ENABLE_OBJC_WEAK = YES; 478 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 479 | CLANG_WARN_BOOL_CONVERSION = YES; 480 | CLANG_WARN_COMMA = YES; 481 | CLANG_WARN_CONSTANT_CONVERSION = YES; 482 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 483 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 484 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 485 | CLANG_WARN_EMPTY_BODY = YES; 486 | CLANG_WARN_ENUM_CONVERSION = YES; 487 | CLANG_WARN_INFINITE_RECURSION = YES; 488 | CLANG_WARN_INT_CONVERSION = YES; 489 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 490 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 491 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 492 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 493 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 494 | CLANG_WARN_STRICT_PROTOTYPES = YES; 495 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 496 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 497 | CLANG_WARN_UNREACHABLE_CODE = YES; 498 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 499 | CODE_SIGN_IDENTITY = "iPhone Developer"; 500 | COPY_PHASE_STRIP = NO; 501 | DEBUG_INFORMATION_FORMAT = dwarf; 502 | ENABLE_STRICT_OBJC_MSGSEND = YES; 503 | ENABLE_TESTABILITY = YES; 504 | GCC_C_LANGUAGE_STANDARD = gnu11; 505 | GCC_DYNAMIC_NO_PIC = NO; 506 | GCC_NO_COMMON_BLOCKS = YES; 507 | GCC_OPTIMIZATION_LEVEL = 0; 508 | GCC_PREPROCESSOR_DEFINITIONS = ( 509 | "DEBUG=1", 510 | "$(inherited)", 511 | ); 512 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 513 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 514 | GCC_WARN_UNDECLARED_SELECTOR = YES; 515 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 516 | GCC_WARN_UNUSED_FUNCTION = YES; 517 | GCC_WARN_UNUSED_VARIABLE = YES; 518 | IPHONEOS_DEPLOYMENT_TARGET = 12.0; 519 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 520 | MTL_FAST_MATH = YES; 521 | ONLY_ACTIVE_ARCH = YES; 522 | SDKROOT = iphoneos; 523 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 524 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 525 | }; 526 | name = Debug; 527 | }; 528 | 62748BE72175084C000B403B /* Release */ = { 529 | isa = XCBuildConfiguration; 530 | buildSettings = { 531 | ALWAYS_SEARCH_USER_PATHS = NO; 532 | CLANG_ANALYZER_NONNULL = YES; 533 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 534 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 535 | CLANG_CXX_LIBRARY = "libc++"; 536 | CLANG_ENABLE_MODULES = YES; 537 | CLANG_ENABLE_OBJC_ARC = YES; 538 | CLANG_ENABLE_OBJC_WEAK = YES; 539 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 540 | CLANG_WARN_BOOL_CONVERSION = YES; 541 | CLANG_WARN_COMMA = YES; 542 | CLANG_WARN_CONSTANT_CONVERSION = YES; 543 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 544 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 545 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 546 | CLANG_WARN_EMPTY_BODY = YES; 547 | CLANG_WARN_ENUM_CONVERSION = YES; 548 | CLANG_WARN_INFINITE_RECURSION = YES; 549 | CLANG_WARN_INT_CONVERSION = YES; 550 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 551 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 552 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 553 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 554 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 555 | CLANG_WARN_STRICT_PROTOTYPES = YES; 556 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 557 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 558 | CLANG_WARN_UNREACHABLE_CODE = YES; 559 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 560 | CODE_SIGN_IDENTITY = "iPhone Developer"; 561 | COPY_PHASE_STRIP = NO; 562 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 563 | ENABLE_NS_ASSERTIONS = NO; 564 | ENABLE_STRICT_OBJC_MSGSEND = YES; 565 | GCC_C_LANGUAGE_STANDARD = gnu11; 566 | GCC_NO_COMMON_BLOCKS = YES; 567 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 568 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 569 | GCC_WARN_UNDECLARED_SELECTOR = YES; 570 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 571 | GCC_WARN_UNUSED_FUNCTION = YES; 572 | GCC_WARN_UNUSED_VARIABLE = YES; 573 | IPHONEOS_DEPLOYMENT_TARGET = 12.0; 574 | MTL_ENABLE_DEBUG_INFO = NO; 575 | MTL_FAST_MATH = YES; 576 | SDKROOT = iphoneos; 577 | SWIFT_COMPILATION_MODE = wholemodule; 578 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 579 | VALIDATE_PRODUCT = YES; 580 | }; 581 | name = Release; 582 | }; 583 | 62748BE92175084C000B403B /* Debug */ = { 584 | isa = XCBuildConfiguration; 585 | buildSettings = { 586 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 587 | CODE_SIGN_STYLE = Automatic; 588 | DEVELOPMENT_TEAM = TVE3XYL8J6; 589 | INFOPLIST_FILE = Recipes/Info.plist; 590 | LD_RUNPATH_SEARCH_PATHS = ( 591 | "$(inherited)", 592 | "@executable_path/Frameworks", 593 | ); 594 | PRODUCT_BUNDLE_IDENTIFIER = com.mattiacantalu.Recipes; 595 | PRODUCT_NAME = "$(TARGET_NAME)"; 596 | SWIFT_VERSION = 4.2; 597 | TARGETED_DEVICE_FAMILY = "1,2"; 598 | }; 599 | name = Debug; 600 | }; 601 | 62748BEA2175084C000B403B /* Release */ = { 602 | isa = XCBuildConfiguration; 603 | buildSettings = { 604 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 605 | CODE_SIGN_STYLE = Automatic; 606 | DEVELOPMENT_TEAM = TVE3XYL8J6; 607 | INFOPLIST_FILE = Recipes/Info.plist; 608 | LD_RUNPATH_SEARCH_PATHS = ( 609 | "$(inherited)", 610 | "@executable_path/Frameworks", 611 | ); 612 | PRODUCT_BUNDLE_IDENTIFIER = com.mattiacantalu.Recipes; 613 | PRODUCT_NAME = "$(TARGET_NAME)"; 614 | SWIFT_VERSION = 4.2; 615 | TARGETED_DEVICE_FAMILY = "1,2"; 616 | }; 617 | name = Release; 618 | }; 619 | 62748BEC2175084C000B403B /* Debug */ = { 620 | isa = XCBuildConfiguration; 621 | buildSettings = { 622 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 623 | BUNDLE_LOADER = "$(TEST_HOST)"; 624 | CODE_SIGN_STYLE = Automatic; 625 | DEVELOPMENT_TEAM = TVE3XYL8J6; 626 | INFOPLIST_FILE = RecipesTests/Info.plist; 627 | LD_RUNPATH_SEARCH_PATHS = ( 628 | "$(inherited)", 629 | "@executable_path/Frameworks", 630 | "@loader_path/Frameworks", 631 | ); 632 | PRODUCT_BUNDLE_IDENTIFIER = com.mattiacantalu.RecipesTests; 633 | PRODUCT_NAME = "$(TARGET_NAME)"; 634 | SWIFT_VERSION = 4.2; 635 | TARGETED_DEVICE_FAMILY = "1,2"; 636 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/RecipesApp.app/RecipesApp"; 637 | }; 638 | name = Debug; 639 | }; 640 | 62748BED2175084C000B403B /* Release */ = { 641 | isa = XCBuildConfiguration; 642 | buildSettings = { 643 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 644 | BUNDLE_LOADER = "$(TEST_HOST)"; 645 | CODE_SIGN_STYLE = Automatic; 646 | DEVELOPMENT_TEAM = TVE3XYL8J6; 647 | INFOPLIST_FILE = RecipesTests/Info.plist; 648 | LD_RUNPATH_SEARCH_PATHS = ( 649 | "$(inherited)", 650 | "@executable_path/Frameworks", 651 | "@loader_path/Frameworks", 652 | ); 653 | PRODUCT_BUNDLE_IDENTIFIER = com.mattiacantalu.RecipesTests; 654 | PRODUCT_NAME = "$(TARGET_NAME)"; 655 | SWIFT_VERSION = 4.2; 656 | TARGETED_DEVICE_FAMILY = "1,2"; 657 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/RecipesApp.app/RecipesApp"; 658 | }; 659 | name = Release; 660 | }; 661 | /* End XCBuildConfiguration section */ 662 | 663 | /* Begin XCConfigurationList section */ 664 | 62748BC62175084B000B403B /* Build configuration list for PBXProject "RecipesApp" */ = { 665 | isa = XCConfigurationList; 666 | buildConfigurations = ( 667 | 62748BE62175084C000B403B /* Debug */, 668 | 62748BE72175084C000B403B /* Release */, 669 | ); 670 | defaultConfigurationIsVisible = 0; 671 | defaultConfigurationName = Release; 672 | }; 673 | 62748BE82175084C000B403B /* Build configuration list for PBXNativeTarget "RecipesApp" */ = { 674 | isa = XCConfigurationList; 675 | buildConfigurations = ( 676 | 62748BE92175084C000B403B /* Debug */, 677 | 62748BEA2175084C000B403B /* Release */, 678 | ); 679 | defaultConfigurationIsVisible = 0; 680 | defaultConfigurationName = Release; 681 | }; 682 | 62748BEB2175084C000B403B /* Build configuration list for PBXNativeTarget "RecipesAppTests" */ = { 683 | isa = XCConfigurationList; 684 | buildConfigurations = ( 685 | 62748BEC2175084C000B403B /* Debug */, 686 | 62748BED2175084C000B403B /* Release */, 687 | ); 688 | defaultConfigurationIsVisible = 0; 689 | defaultConfigurationName = Release; 690 | }; 691 | /* End XCConfigurationList section */ 692 | }; 693 | rootObject = 62748BC32175084B000B403B /* Project object */; 694 | } 695 | --------------------------------------------------------------------------------