├── .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 |
--------------------------------------------------------------------------------