├── .gitignore ├── screenshots ├── delete.png ├── error.png ├── filter.png ├── header.png ├── rates1.gif ├── rates2.gif ├── listing.png ├── refreshing.png ├── add-new-currency.png └── select-base-currency.png ├── Rates ├── Assets.xcassets │ ├── Contents.json │ └── AppIcon.appiconset │ │ └── Contents.json ├── Models │ ├── CoreData │ │ ├── Rates.xcdatamodeld │ │ │ ├── .xccurrentversion │ │ │ └── Rates.xcdatamodel │ │ │ │ └── contents │ │ ├── Rate+CoreDataClass.swift │ │ ├── Currency+CoreDataClass.swift │ │ ├── Currency+CoreDataProperties.swift │ │ └── Rate+CoreDataProperties.swift │ ├── Currencies.swift │ ├── ErrorInfo.swift │ ├── ConvertedRate.swift │ ├── RequestError.swift │ └── Quotes.swift ├── Protocols │ ├── Bindable.swift │ ├── Reusable.swift │ ├── NibLoadable.swift │ ├── AppDefaultsConvertible.swift │ └── AlertShowable.swift ├── Extensions │ ├── UITableViewCell_Extensions.swift │ ├── NSManagedObject_Extensions.swift │ └── UITableView_Extensions.swift ├── Views │ ├── CurrencyCell.swift │ ├── RateCell.swift │ ├── CurrencyCell.xib │ ├── RateCell.xib │ └── Base.lproj │ │ └── Main.storyboard ├── Services │ ├── AppDefaults.swift │ ├── NSFetchResultControllerDelegateWrapper.swift │ ├── RequestCaller.swift │ └── APIService.swift ├── Base.lproj │ └── LaunchScreen.storyboard ├── Info.plist ├── AppDelegate.swift ├── ViewModels │ ├── CurrenciesViewModel.swift │ └── RatesViewModel.swift └── ViewControllers │ ├── CurrenciesViewController.swift │ └── RatesViewController.swift ├── Rates.xcodeproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist ├── xcshareddata │ └── xcschemes │ │ └── Rates.xcscheme └── project.pbxproj ├── mock-resources └── api │ ├── list.json │ └── live.json ├── .travis.yml ├── .github └── workflows │ └── ios.yml ├── RatesTests ├── TestHelpers │ ├── InMemoryAppDefaults.swift │ └── RequestMocker.swift ├── Info.plist ├── APIServiceTests.swift └── RatesViewModelTests.swift ├── RatesUITests ├── Info.plist └── RatesUITests.swift ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | DerivedData/* 2 | xcuserdata -------------------------------------------------------------------------------- /screenshots/delete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaelhenry/Rates/HEAD/screenshots/delete.png -------------------------------------------------------------------------------- /screenshots/error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaelhenry/Rates/HEAD/screenshots/error.png -------------------------------------------------------------------------------- /screenshots/filter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaelhenry/Rates/HEAD/screenshots/filter.png -------------------------------------------------------------------------------- /screenshots/header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaelhenry/Rates/HEAD/screenshots/header.png -------------------------------------------------------------------------------- /screenshots/rates1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaelhenry/Rates/HEAD/screenshots/rates1.gif -------------------------------------------------------------------------------- /screenshots/rates2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaelhenry/Rates/HEAD/screenshots/rates2.gif -------------------------------------------------------------------------------- /screenshots/listing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaelhenry/Rates/HEAD/screenshots/listing.png -------------------------------------------------------------------------------- /screenshots/refreshing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaelhenry/Rates/HEAD/screenshots/refreshing.png -------------------------------------------------------------------------------- /Rates/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /screenshots/add-new-currency.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaelhenry/Rates/HEAD/screenshots/add-new-currency.png -------------------------------------------------------------------------------- /screenshots/select-base-currency.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaelhenry/Rates/HEAD/screenshots/select-base-currency.png -------------------------------------------------------------------------------- /Rates.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Rates.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Rates/Models/CoreData/Rates.xcdatamodeld/.xccurrentversion: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | _XCCurrentVersionName 6 | Rates.xcdatamodel 7 | 8 | 9 | -------------------------------------------------------------------------------- /Rates/Models/Currencies.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Currencies.swift 3 | // Rates 4 | // 5 | // Created by Michael Henry Pantaleon on 2019/08/28. 6 | // Copyright © 2019 Michael Henry Pantaleon. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct Currencies:Decodable { 12 | 13 | var currencies:[String:String] 14 | } 15 | -------------------------------------------------------------------------------- /Rates/Protocols/Bindable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Bindable.swift 3 | // Rates 4 | // 5 | // Created by Michael Henry Pantaleon on 2019/08/28. 6 | // Copyright © 2019 Michael Henry Pantaleon. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | protocol Bindable { 12 | 13 | associatedtype T 14 | 15 | func bind(_ data: T) 16 | } 17 | -------------------------------------------------------------------------------- /mock-resources/api/list.json: -------------------------------------------------------------------------------- 1 | { 2 | "success": true, 3 | "terms": "https://currencylayer.com/terms", 4 | "privacy": "https://currencylayer.com/privacy", 5 | "currencies": { 6 | "AED": "United Arab Emirates Dirham", 7 | "JPY": "Japanese Yen", 8 | "USD": "United States Dollar", 9 | "ZWL": "Zimbabwean Dollar" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /mock-resources/api/live.json: -------------------------------------------------------------------------------- 1 | { 2 | "success": true, 3 | "terms": "https://currencylayer.com/terms", 4 | "privacy": "https://currencylayer.com/privacy", 5 | "timestamp": 1566922086, 6 | "source": "USD", 7 | "quotes": { 8 | "USDAED": 3.673007, 9 | "USDJPY": 105.760985, 10 | "USDUSD": 1, 11 | "USDZWL": 322.000001 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Rates/Extensions/UITableViewCell_Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UITableViewCell_Extensions.swift 3 | // Rates 4 | // 5 | // Created by Michael Henry Pantaleon on 2019/08/28. 6 | // Copyright © 2019 Michael Henry Pantaleon. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension UITableViewCell:NibLoadable {} 12 | extension UITableViewCell:Reusable {} 13 | -------------------------------------------------------------------------------- /Rates/Models/CoreData/Rate+CoreDataClass.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Rate+CoreDataClass.swift 3 | // Rates 4 | // 5 | // Created by Michael Henry Pantaleon on 2019/08/29. 6 | // Copyright © 2019 Michael Henry Pantaleon. All rights reserved. 7 | // 8 | // 9 | 10 | import Foundation 11 | import CoreData 12 | 13 | @objc(Rate) 14 | public class Rate: NSManagedObject { 15 | 16 | } 17 | -------------------------------------------------------------------------------- /Rates/Models/CoreData/Currency+CoreDataClass.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Currency+CoreDataClass.swift 3 | // Rates 4 | // 5 | // Created by Michael Henry Pantaleon on 2019/08/28. 6 | // Copyright © 2019 Michael Henry Pantaleon. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import CoreData 11 | 12 | @objc(Currency) 13 | public class Currency: NSManagedObject { 14 | 15 | } 16 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | osx_image: xcode10.2 2 | language: objective-c 3 | 4 | jobs: 5 | include: 6 | - stage: Testing Rates App 7 | script: 8 | - | 9 | set -eo pipefail 10 | xcodebuild test -enableCodeCoverage YES -project Rates.xcodeproj -scheme Rates \ 11 | -sdk iphonesimulator \ 12 | -destination 'platform=iOS Simulator,name=iPhone Xʀ' | xcpretty 13 | -------------------------------------------------------------------------------- /Rates/Models/ErrorInfo.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ErrorInfo.swift 3 | // Rates 4 | // 5 | // Created by Michael Henry Pantaleon on 2019/08/28. 6 | // Copyright © 2019 Michael Henry Pantaleon. All rights reserved. 7 | // 8 | 9 | struct ErrorDetail:Decodable { 10 | 11 | let code:Int 12 | let info:String 13 | } 14 | 15 | struct ErrorInfo:Error, Decodable { 16 | 17 | var error: ErrorDetail 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/ios.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: macOS-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v1 12 | - name: Run Test 13 | run: | 14 | set -eo pipefail 15 | xcodebuild test -enableCodeCoverage YES -project Rates.xcodeproj -scheme Rates \ 16 | -sdk iphonesimulator \ 17 | -destination 'platform=iOS Simulator,name=iPhone Xʀ' | xcpretty 18 | -------------------------------------------------------------------------------- /Rates/Protocols/Reusable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Reusable.swift 3 | // Rates 4 | // 5 | // Created by Michael Henry Pantaleon on 2019/08/28. 6 | // Copyright © 2019 Michael Henry Pantaleon. All rights reserved. 7 | // 8 | 9 | protocol Reusable { 10 | static var reuseIdentifier: String { get } 11 | } 12 | 13 | extension Reusable { 14 | static var reuseIdentifier: String { 15 | let className = String(describing: self) 16 | return "\(className)" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Rates/Protocols/NibLoadable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NibLoadable.swift 3 | // Rates 4 | // 5 | // Created by Michael Henry Pantaleon on 2019/08/28. 6 | // Copyright © 2019 Michael Henry Pantaleon. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | protocol NibLoadable { 11 | static var nib:UINib { get } 12 | } 13 | 14 | extension NibLoadable where Self: UIView { 15 | static var nib: UINib { 16 | let className = String(describing: self) 17 | return UINib(nibName: className, bundle: Bundle(for: self)) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Rates/Models/ConvertedRate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EquivalentRate.swift 3 | // Rates 4 | // 5 | // Created by Michael Henry Pantaleon on 2019/08/29. 6 | // Copyright © 2019 Michael Henry Pantaleon. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct EquivalentRate { 12 | var currencyCode:String? 13 | var value:NSDecimalNumber? 14 | } 15 | 16 | extension Rate { 17 | func equivalentRate(at rate: NSDecimalNumber) -> EquivalentRate { 18 | return EquivalentRate(currencyCode: currencyCode, value: value?.multiplying(by: rate)) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Rates/Extensions/NSManagedObject_Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSManagedObject_Extensions.swift 3 | // Rates 4 | // 5 | // Created by Michael Henry Pantaleon on 2019/09/01. 6 | // Copyright © 2019 Michael Henry Pantaleon. All rights reserved. 7 | // 8 | 9 | import CoreData 10 | 11 | public extension NSManagedObject { 12 | 13 | convenience init(ctx: NSManagedObjectContext) { 14 | let name = String(describing: type(of: self)) 15 | let entity = NSEntityDescription.entity(forEntityName: name, in: ctx)! 16 | self.init(entity: entity, insertInto: ctx) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Rates/Views/CurrencyCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CurrencyCell.swift 3 | // Rates 4 | // 5 | // Created by Michael Henry Pantaleon on 2019/08/28. 6 | // Copyright © 2019 Michael Henry Pantaleon. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class CurrencyCell: UITableViewCell, Bindable{ 12 | 13 | typealias T = Currency 14 | 15 | @IBOutlet weak var codeLabel:UILabel! 16 | @IBOutlet weak var nameLabel:UILabel! 17 | 18 | override func awakeFromNib() { 19 | super.awakeFromNib() 20 | // Initialization code 21 | } 22 | 23 | func bind(_ data:T) { 24 | codeLabel.text = data.code 25 | nameLabel.text = data.name 26 | } 27 | } 28 | 29 | -------------------------------------------------------------------------------- /Rates/Protocols/AppDefaultsConvertible.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDefaultsConvertible.swift 3 | // Rates 4 | // 5 | // Created by Michael Henry Pantaleon on 2019/08/30. 6 | // Copyright © 2019 Michael Henry Pantaleon. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum AppDefaultsKey:String { 12 | case lastFetchTimestamp // the last time we execute the fetch request. 13 | case lastQuotesTimestamp // the actual timestamp of the quotes 14 | case baseCurrencyCode 15 | } 16 | 17 | protocol AppDefaultsConvertible { 18 | func get(for key:AppDefaultsKey) -> T? 19 | func set(value: T?, for key:AppDefaultsKey) 20 | func remove(key:AppDefaultsKey) 21 | } 22 | -------------------------------------------------------------------------------- /Rates/Models/RequestError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RequestError.swift 3 | // Rates 4 | // 5 | // Created by Michael Henry Pantaleon on 2019/08/29. 6 | // Copyright © 2019 Michael Henry Pantaleon. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum RequestError:Error, CustomStringConvertible { 12 | case responseError(ErrorDetail) 13 | case unknownError(String) 14 | case unreachable 15 | 16 | var description: String { 17 | switch self { 18 | case .responseError(let detail): 19 | return detail.info 20 | case .unknownError(let msg): 21 | return msg 22 | case .unreachable: 23 | return "Please check your internet connection." 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /RatesTests/TestHelpers/InMemoryAppDefaults.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InMemoryAppDefaults.swift 3 | // RatesTests 4 | // 5 | // Created by Michael Henry Pantaleon on 2019/08/30. 6 | // Copyright © 2019 Michael Henry Pantaleon. All rights reserved. 7 | // 8 | 9 | @testable import Rates 10 | 11 | class InMemoryAppDefaults:AppDefaultsConvertible { 12 | 13 | var defaults:[AppDefaultsKey: Any] = [:] 14 | 15 | func get(for key:AppDefaultsKey) -> T? { 16 | return defaults[key] as? T 17 | } 18 | 19 | func set(value: T?, for key:AppDefaultsKey) { 20 | defaults[key] = value 21 | } 22 | 23 | func remove(key:AppDefaultsKey) { 24 | defaults.removeValue(forKey: key) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Rates/Services/AppDefaults.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDefaults.swift 3 | // Rates 4 | // 5 | // Created by Michael Henry Pantaleon on 2019/08/30. 6 | // Copyright © 2019 Michael Henry Pantaleon. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | class AppDefaults:AppDefaultsConvertible { 12 | 13 | static let shared = AppDefaults() 14 | 15 | private let defaults = UserDefaults.standard 16 | 17 | func get(for key:AppDefaultsKey) -> T? { 18 | return defaults.object(forKey: key.rawValue) as? T 19 | } 20 | 21 | func set(value: T?, for key:AppDefaultsKey) { 22 | defaults.set(value, forKey: key.rawValue) 23 | } 24 | 25 | func remove(key: AppDefaultsKey) { 26 | defaults.removeObject(forKey: key.rawValue) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /RatesTests/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 | -------------------------------------------------------------------------------- /RatesUITests/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 | -------------------------------------------------------------------------------- /Rates/Extensions/UITableView_Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UITableView_Extensions.swift 3 | // Rates 4 | // 5 | // Created by Michael Henry Pantaleon on 2019/08/28. 6 | // Copyright © 2019 Michael Henry Pantaleon. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | extension UITableView { 11 | 12 | func register(_ c: T.Type) { 13 | register(T.nib, forCellReuseIdentifier: T.reuseIdentifier) 14 | } 15 | 16 | func dequeue(_ : T.Type) -> T? { 17 | return dequeueReusableCell(withIdentifier: T.reuseIdentifier) as? T 18 | } 19 | 20 | // For those that implemented Bindable, so no need to explicitly define the type 21 | func dequeue(_ : T.Type) -> T? { 22 | return dequeueReusableCell(withIdentifier: T.reuseIdentifier) as? T 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Rates/Views/RateCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RateCell.swift 3 | // Rates 4 | // 5 | // Created by Michael Henry Pantaleon on 2019/08/28. 6 | // Copyright © 2019 Michael Henry Pantaleon. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class RateCell: UITableViewCell, Bindable { 12 | 13 | typealias T = EquivalentRate 14 | 15 | @IBOutlet weak var codeLabel:UILabel! 16 | @IBOutlet weak var valueLabel:UILabel! 17 | @IBOutlet weak var containerView:UIView! 18 | 19 | override func awakeFromNib() { 20 | super.awakeFromNib() 21 | selectionStyle = .none 22 | containerView.layer.cornerRadius = 5.0 23 | containerView.clipsToBounds = true 24 | } 25 | 26 | func bind(_ data: T) { 27 | codeLabel.text = data.currencyCode 28 | valueLabel.text = data.value?.stringValue 29 | } 30 | } 31 | 32 | -------------------------------------------------------------------------------- /Rates/Protocols/AlertShowable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AlertShowable.swift 3 | // Rates 4 | // 5 | // Created by Michael Henry Pantaleon on 2019/08/29. 6 | // Copyright © 2019 Michael Henry Pantaleon. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | protocol AlertShowable { 12 | 13 | func showAlert( 14 | title:String?, 15 | message:String?, 16 | animated:Bool, 17 | actions:[UIAlertAction]) 18 | } 19 | 20 | extension AlertShowable where Self:UIViewController { 21 | 22 | func showAlert( 23 | title:String?, 24 | message:String?, 25 | animated:Bool = true, 26 | actions:[UIAlertAction]) { 27 | 28 | let alert = UIAlertController( 29 | title: title, 30 | message: message, 31 | preferredStyle: .alert) 32 | actions.forEach { 33 | alert.addAction($0) 34 | } 35 | present(alert, animated: animated) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Rates/Models/Quotes.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Quotes.swift 3 | // Rates 4 | // 5 | // Created by Michael Henry Pantaleon on 2019/08/28. 6 | // Copyright © 2019 Michael Henry Pantaleon. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct Quotes:Decodable { 12 | 13 | typealias ConversionCode = String 14 | 15 | var timestamp:Date 16 | var source:String 17 | var quotes:[ConversionCode:Double] 18 | 19 | enum CodingKeys:String, CodingKey { 20 | case timestamp 21 | case source 22 | case quotes 23 | } 24 | 25 | init(from decoder: Decoder) throws { 26 | let container = try decoder.container(keyedBy: CodingKeys.self) 27 | let timestampUnix = try container.decode(TimeInterval.self, forKey: .timestamp) 28 | quotes = try container.decode([ConversionCode:Double].self, forKey: .quotes) 29 | source = try container.decode(String.self, forKey: .source) 30 | timestamp = Date(timeIntervalSince1970: timestampUnix) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 Michael Henry Pantaleon 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /Rates/Models/CoreData/Currency+CoreDataProperties.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Currency+CoreDataProperties.swift 3 | // Rates 4 | // 5 | // Created by Kel Joyz on 2019/08/28. 6 | // Copyright © 2019 Michael Henry Pantaleon. All rights reserved. 7 | // 8 | // 9 | 10 | import Foundation 11 | import CoreData 12 | 13 | extension Currency { 14 | 15 | @nonobjc public class func fetchRequest(searchText:String = "") -> NSFetchRequest { 16 | let request = NSFetchRequest(entityName: "Currency") 17 | request.fetchBatchSize = 30 18 | let nameSort = NSSortDescriptor(keyPath: \Currency.name, ascending: true) 19 | request.sortDescriptors = [nameSort] 20 | if !searchText.isEmpty { 21 | let predicate = NSPredicate(searchText: searchText) 22 | request.predicate = predicate 23 | } 24 | 25 | return request 26 | } 27 | 28 | @NSManaged public var code: String? 29 | @NSManaged public var name: String? 30 | @NSManaged public var rate: Rate? 31 | } 32 | 33 | extension NSPredicate { 34 | convenience init(searchText:String) { 35 | self.init(format: "code CONTAINS[cd] %@ OR name CONTAINS[cd] %@",searchText, searchText) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /RatesUITests/RatesUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RatesUITests.swift 3 | // RatesUITests 4 | // 5 | // Created by Michael Henry Pantaleon on 2019/08/27. 6 | // Copyright © 2019 Michael Henry Pantaleon. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | class RatesUITests: XCTestCase { 12 | 13 | override func setUp() { 14 | // Put setup code here. This method is called before the invocation of each test method in the class. 15 | 16 | // In UI tests it is usually best to stop immediately when a failure occurs. 17 | continueAfterFailure = false 18 | 19 | // UI tests must launch the application that they test. Doing this in setup will make sure it happens for each test method. 20 | XCUIApplication().launch() 21 | 22 | // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. 23 | } 24 | 25 | override func tearDown() { 26 | // Put teardown code here. This method is called after the invocation of each test method in the class. 27 | } 28 | 29 | func testExample() { 30 | // Use recording to get started writing UI tests. 31 | // Use XCTAssert and related functions to verify your tests produce the correct results. 32 | } 33 | 34 | } 35 | 36 | -------------------------------------------------------------------------------- /Rates/Models/CoreData/Rate+CoreDataProperties.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Rate+CoreDataProperties.swift 3 | // Rates 4 | // 5 | // Created by Michael Henry Pantaleon on 2019/08/29. 6 | // Copyright © 2019 Michael Henry Pantaleon. All rights reserved. 7 | // 8 | // 9 | 10 | import Foundation 11 | import CoreData 12 | 13 | extension Rate { 14 | 15 | @nonobjc public class func fetchRequest() -> NSFetchRequest { 16 | let request = NSFetchRequest(entityName: "Rate") 17 | request.predicate = NSPredicate(format: "active = true") 18 | request.fetchBatchSize = 30 19 | let nameSort = NSSortDescriptor(keyPath: \Rate.currencyCode, ascending: true) 20 | request.sortDescriptors = [nameSort] 21 | return request 22 | } 23 | 24 | @nonobjc public class func find(currencyCode: String, in context: NSManagedObjectContext) -> Rate? { 25 | let fetchRequest:NSFetchRequest = Rate.fetchRequest() 26 | fetchRequest.predicate = NSPredicate(format: "currencyCode = %@", currencyCode) 27 | fetchRequest.fetchLimit = 1 28 | return try? context.fetch(fetchRequest).first 29 | } 30 | 31 | @nonobjc public class func findOrCreate(currencyCode: String, in context: NSManagedObjectContext) -> Rate { 32 | guard let result = Rate.find(currencyCode: currencyCode, in: context) else { 33 | return Rate(ctx: context) 34 | } 35 | return result 36 | } 37 | 38 | 39 | @NSManaged public var value: NSDecimalNumber? 40 | @NSManaged public var currencyCode: String? 41 | @NSManaged public var active: Bool 42 | } 43 | 44 | -------------------------------------------------------------------------------- /RatesTests/TestHelpers/RequestMocker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RequestMocker.swift 3 | // Rates 4 | // 5 | // Created by Michael Henry Pantaleon on 2019/08/29. 6 | // Copyright © 2019 Michael Henry Pantaleon. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | class RequestMocker:URLProtocol { 12 | override class func canInit(with request: URLRequest) -> Bool { 13 | return true 14 | } 15 | 16 | override class func canonicalRequest(for request: URLRequest) -> URLRequest { 17 | return request 18 | } 19 | 20 | override func startLoading() { 21 | guard let path = request.url?.path, 22 | let mockUrl = urlOfFile(endpoint: path), 23 | let mockData = try? Data(contentsOf: mockUrl) else { return } 24 | 25 | let urlResponse = HTTPURLResponse( 26 | url: request.url!, 27 | statusCode: 200, // TODO: Assume only Success for now,since we only fetch data for now. BUT this must be changed. 28 | httpVersion: nil, 29 | headerFields: nil)! 30 | 31 | client?.urlProtocol(self, didReceive: urlResponse, cacheStoragePolicy: .notAllowed) 32 | client?.urlProtocol(self, didLoad: mockData) 33 | client?.urlProtocolDidFinishLoading(self) 34 | } 35 | 36 | override func stopLoading() { 37 | 38 | } 39 | 40 | private func urlOfFile(endpoint:String) -> URL? { 41 | 42 | guard let mockResourcesFolderUrl = Bundle( 43 | for: type(of: self)) 44 | .resourceURL?.appendingPathComponent("mock-resources") 45 | else { 46 | fatalError("Cannot find the mock-resources folder") 47 | } 48 | return mockResourcesFolderUrl.appendingPathComponent("\(endpoint).json") 49 | } 50 | 51 | } 52 | -------------------------------------------------------------------------------- /Rates/Models/CoreData/Rates.xcdatamodeld/Rates.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /Rates/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 | -------------------------------------------------------------------------------- /Rates/Services/NSFetchResultControllerDelegateWrapper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSFetchResultControllerDelegateWrapper.swift 3 | // Rates 4 | // 5 | // Created by Michael Henry Pantaleon on 2019/08/28. 6 | // Copyright © 2019 Michael Henry Pantaleon. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import CoreData 11 | 12 | /// NSFetchedResultsControllerDelegateWrapper is a wrapper that will help to avoid repeatition on setting up the NSFetchResultControllerDelegate 13 | class NSFetchedResultsControllerDelegateWrapper:NSObject, NSFetchedResultsControllerDelegate { 14 | 15 | private var onWillChangeContent:(() -> Void)? 16 | private var onDidChangeContent:(() -> Void)? 17 | private var onChange:(( 18 | _ indexPath:IndexPath?, 19 | _ type: NSFetchedResultsChangeType, 20 | _ newIndexPath:IndexPath?) -> Void)? 21 | 22 | init( 23 | onWillChangeContent:(() -> Void)?, 24 | onDidChangeContent:(() -> Void)?, 25 | onChange:(( 26 | _ indexPath:IndexPath?, 27 | _ type: NSFetchedResultsChangeType, 28 | _ newIndexPath:IndexPath?) -> Void)?) { 29 | self.onWillChangeContent = onWillChangeContent 30 | self.onDidChangeContent = onDidChangeContent 31 | self.onChange = onChange 32 | } 33 | 34 | func controllerWillChangeContent(_ controller: NSFetchedResultsController) { 35 | self.onWillChangeContent?() 36 | } 37 | 38 | func controllerDidChangeContent(_ controller: NSFetchedResultsController) { 39 | self.onDidChangeContent?() 40 | } 41 | 42 | func controller(_ controller: NSFetchedResultsController, 43 | didChange anObject: Any, at indexPath: IndexPath?, 44 | for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) { 45 | self.onChange?(indexPath, type, newIndexPath) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Rates/Services/RequestCaller.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RequestCaller.swift 3 | // Rates 4 | // 5 | // Created by Michael Henry Pantaleon on 2019/08/28. 6 | // Copyright © 2019 Michael Henry Pantaleon. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | class RequestCaller { 12 | 13 | private lazy var decoder = JSONDecoder() 14 | private let urlSession:URLSession = URLSession.shared 15 | 16 | /// A request call that provide Generic Decodable model 17 | func call( 18 | request:URLRequest, 19 | completion: @escaping(Result) -> Void) { 20 | 21 | let task = urlSession.dataTask(with: request) { [weak self] (data, response, error) in 22 | guard let weakSelf = self else { return } 23 | // Check if the request was reachable. 24 | guard let httpResponse = response as? HTTPURLResponse, 25 | httpResponse.statusCode != 0 else { 26 | completion(Result.failure(.unreachable)) 27 | return 28 | } 29 | guard let responseData = data else { 30 | fatalError(""" 31 | We're expecting a data to decode. 32 | Response must not be empty! 33 | Else it's better to use a different method that doesn't Decode the response object for better result. 34 | """) 35 | } 36 | do { 37 | if let obj = try? weakSelf.decoder.decode(Model.self, from: responseData) { 38 | completion(Result.success(obj)) 39 | } else { 40 | // then may be it's the error info. 41 | let errObj = try weakSelf.decoder.decode(ErrorInfo.self, from: responseData) 42 | completion(Result.failure(RequestError.responseError(errObj.error))) 43 | } 44 | } catch { 45 | // Maybe related to other things like invalid json format and etc. 46 | completion(Result.failure(RequestError.unknownError(error.localizedDescription))) 47 | } 48 | } 49 | task.resume() 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Rates/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 | NSExceptionDomains 47 | 48 | apilayer.net 49 | 50 | NSExceptionAllowsInsecureHTTPLoads 51 | 52 | 53 | 54 | 55 | CurrencyLayer 56 | 57 | APIKey 58 | 0e95ff074fc3d9a7352cae4a4182224f 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /Rates/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 | } -------------------------------------------------------------------------------- /Rates/Services/APIService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // APIService.swift 3 | // Rates 4 | // 5 | // Created by Michael Henry Pantaleon on 2019/08/28. 6 | // Copyright © 2019 Michael Henry Pantaleon. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | class APIService { 12 | 13 | static let shared = APIService() 14 | private lazy var requestCaller = RequestCaller() 15 | private let baseUrl:URL = URL(string: "http://apilayer.net")! 16 | 17 | private lazy var apiKey:String = { 18 | // I do recommend to set and override the PRODUCTION API KEY 19 | // via CI ENVIRONMENT Variable 20 | // And on Development Environment we provide a default value so that 21 | // the app will continue to work without any additional configuration, 22 | // ONLY if this is in a PRIVATE Repo. 23 | // 24 | // But for DEMO purposes, I will include this API Key 25 | // And there is a chance that this will revoke in the future. 26 | guard let _currencyLayerConfig = Bundle.main 27 | .object(forInfoDictionaryKey: "CurrencyLayer") as? [String: String], 28 | let _apiKey = _currencyLayerConfig["APIKey"] else { 29 | fatalError("No CurrencyLayer.APIKey found") 30 | } 31 | return _apiKey 32 | }() 33 | 34 | func fetchCurrencies(completion: @escaping(Result) -> Void) { 35 | let endpoint = "api/list" 36 | requestCaller.call(request: request(from: endpoint), completion: completion) 37 | } 38 | 39 | func fetchLive(source:String, completion: @escaping(Result) -> Void) { 40 | let endpoint = "api/live" 41 | requestCaller.call( 42 | request: request(from: endpoint, 43 | queryParams: ["source": source]), 44 | completion: completion) 45 | } 46 | 47 | private func request(from endpoint:String, queryParams:[String:String] = [:]) -> URLRequest { 48 | var components = URLComponents(url: baseUrl.appendingPathComponent(endpoint), resolvingAgainstBaseURL: true)! 49 | var items = [URLQueryItem(name: "access_key", value: apiKey)] 50 | queryParams.forEach { 51 | items.append(URLQueryItem(name: $0.key, value: $0.value)) 52 | } 53 | components.queryItems = items 54 | return URLRequest(url: components.url!) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /RatesTests/APIServiceTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // APIServiceTests.swift 3 | // RatesTests 4 | // 5 | // Created by Michael Henry Pantaleon on 2019/08/28. 6 | // Copyright © 2019 Michael Henry Pantaleon. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import Rates 11 | 12 | class APIServiceTests: XCTestCase { 13 | 14 | let apiService = APIService() 15 | 16 | override func setUp() { 17 | URLProtocol.registerClass(RequestMocker.self) 18 | } 19 | 20 | func testFetchCurrencies() { 21 | let ex = expectation(description: "must have currencies") 22 | apiService.fetchCurrencies { result in 23 | switch result { 24 | case .success(let value): 25 | let currencies = value.currencies 26 | XCTAssertEqual(currencies.count, 4) 27 | XCTAssertEqual(currencies["USD"], "United States Dollar") 28 | XCTAssertEqual(currencies["JPY"], "Japanese Yen") 29 | XCTAssertEqual(currencies["AED"], "United Arab Emirates Dirham") 30 | XCTAssertEqual(currencies["ZWL"], "Zimbabwean Dollar") 31 | case .failure(let error): 32 | switch error { 33 | case .responseError(let detail): 34 | XCTFail(detail.info) 35 | default: 36 | XCTFail(error.localizedDescription) 37 | } 38 | } 39 | ex.fulfill() 40 | } 41 | wait(for: [ex], timeout: 2.0) 42 | } 43 | 44 | func testFetchQuotes() { 45 | let ex = expectation(description: "must have quotes") 46 | apiService.fetchLive(source: "USA") { result in 47 | switch result { 48 | case .success(let value): 49 | XCTAssertEqual(value.timestamp, Date(timeIntervalSince1970: 1566922086)) 50 | let quotes = value.quotes 51 | XCTAssertEqual(quotes.count, 4) 52 | XCTAssertEqual(quotes["USDAED"], 3.673007) 53 | XCTAssertEqual(quotes["USDJPY"], 105.760985) 54 | XCTAssertEqual(quotes["USDUSD"], 1) 55 | XCTAssertEqual(quotes["USDZWL"], 322.000001) 56 | case .failure(let error): 57 | switch error { 58 | case .responseError(let detail): 59 | XCTFail(detail.info) 60 | default: 61 | XCTFail(error.localizedDescription) 62 | } 63 | } 64 | ex.fulfill() 65 | } 66 | wait(for: [ex], timeout: 2.0) 67 | } 68 | 69 | override func tearDown() { 70 | URLProtocol.unregisterClass(RequestMocker.self) 71 | } 72 | 73 | } 74 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Header](screenshots/header.png) 2 | 3 | [![CI Status](https://img.shields.io/travis/michaelhenry/Rates.svg?style=flat)](https://travis-ci.org/michaelhenry/Rates) [![Version](https://img.shields.io/badge/version-1.0-black.svg?style=flat)](#) 4 | 5 | ![Rates 2](screenshots/rates2.gif) ![Rates 1](screenshots/rates1.gif) 6 | 7 | ![Listing](screenshots/listing.png) ![Refreshing](screenshots/refreshing.png) ![Delete](screenshots/delete.png) ![Select Base Currency](screenshots/select-base-currency.png) ![Add New Currency](screenshots/add-new-currency.png) 8 | 9 | # API 10 | 11 | This App uses api from [Currency Layer](https://currencylayer.com), Please check their [Documentation](https://currencylayer.com/documentation) for more information. 12 | 13 | # SETUP 14 | 15 | To configure the API, inside the [Info.plist](Rates/Info.plist), under `CurrencyLayer`, 16 | replace `#YOUR_API_KEY` with your actual `APIkey`. 17 | 18 | Example: 19 |   20 | ``` 21 | 22 | 23 | 24 | 25 | ... 26 | CurrencyLayer 27 | 28 | APIKey 29 | #YOUR_API_KEY 30 | 31 | 32 | 33 | ``` 34 | 35 | # Unit Testing 36 | 37 | This project uses the concept of mocking the request to avoid the hitting the server while still using the actual `API` request calls. All requests from the [APIService](Rates/Services/APIService.swift) are all hijacked and replaced with the mock ones. 38 | 39 | You can see all the response of the hijacked request can be found on [mock-resources/api](/mock-resources/api) for more info. The `Path` and `File` under [mock-resources](/mock-resources) folder represents the `Actual Endpoint` of the `Request` 40 | 41 | Example: 42 | endpoint: `/api/list` 43 | 44 | ```yml 45 | - mock-resources: 46 | - api: 47 | - list: 48 | ``` 49 | 50 | > I'm planning to improve this by having a `Code Convention` for writting `HTTP Response` like adding custom `Response Headers`, react to diffrent `HTTPMethod` and `Query Params`, make it `pluggable` and easy to use. Please check this [Hijackr](https://github.com/michaelhenry/Hijackr) for more info in the Future. 51 | 52 | 53 | Please see the [RatesTests](RatesTests) target for more info. 54 | 55 | Test results can also be viewed from [Travis CI - Rates](https://travis-ci.org/michaelhenry/Rates) 56 | 57 | # Notes 58 | 59 | - The App will only execute the actual `refresh` only after 30 minutes from the last successful request to avoid reaching the rate limitation of the API. 60 | - Due to the limitation of the `Free API Key` from [Currency Layer](https://currencylayer.com), switching the `Base Currency` other than `USD` will alert an Error message like: 61 | 62 | > Access Restricted. Your current Subscription plan does not support Source Currency Switching. 63 | 64 | ![Rate Limit Error](screenshots/error.png) 65 | 66 | And to solve this issue, you have to subscribed to their [Paid Service](https://currencylayer.com/product). 67 | 68 | # TODO 69 | 70 | - [ ] Test cases for Failed request. 71 | - [ ] Localization 72 | - [ ] Empty State for List 73 | - [ ] Custom Keyboard Input 74 | - [ ] UI Test cases 75 | - [ ] Additional tests for different iOS and Device version. 76 | - [ ] Reacheability. 77 | 78 | # Contact 79 | 80 | Fore more information, please contact me@iamkel.net 81 | 82 | -------------------------------------------------------------------------------- /Rates/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // Rates 4 | // 5 | // Created by Michael Henry Pantaleon on 2019/08/27. 6 | // Copyright © 2019 Michael Henry Pantaleon. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import CoreData 11 | 12 | @UIApplicationMain 13 | class AppDelegate: UIResponder, UIApplicationDelegate { 14 | 15 | var window: UIWindow? 16 | 17 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 18 | window?.tintColor = .black 19 | return true 20 | } 21 | 22 | func applicationWillResignActive(_ application: UIApplication) { 23 | // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. 24 | // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. 25 | } 26 | 27 | func applicationDidEnterBackground(_ application: UIApplication) { 28 | // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. 29 | // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. 30 | } 31 | 32 | func applicationWillEnterForeground(_ application: UIApplication) { 33 | // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. 34 | } 35 | 36 | func applicationDidBecomeActive(_ application: UIApplication) { 37 | // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. 38 | } 39 | 40 | func applicationWillTerminate(_ application: UIApplication) { 41 | // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. 42 | // Saves changes in the application's managed object context before the application terminates. 43 | self.saveContext() 44 | } 45 | 46 | // MARK: - Core Data stack 47 | 48 | lazy var persistentContainer: NSPersistentContainer = { 49 | /* 50 | The persistent container for the application. This implementation 51 | creates and returns a container, having loaded the store for the 52 | application to it. This property is optional since there are legitimate 53 | error conditions that could cause the creation of the store to fail. 54 | */ 55 | let container = NSPersistentContainer(name: "Rates") 56 | container.loadPersistentStores(completionHandler: { (storeDescription, error) in 57 | if let error = error as NSError? { 58 | // Replace this implementation with code to handle the error appropriately. 59 | // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. 60 | 61 | /* 62 | Typical reasons for an error here include: 63 | * The parent directory does not exist, cannot be created, or disallows writing. 64 | * The persistent store is not accessible, due to permissions or data protection when the device is locked. 65 | * The device is out of space. 66 | * The store could not be migrated to the current model version. 67 | Check the error message to determine what the actual problem was. 68 | */ 69 | fatalError("Unresolved error \(error), \(error.userInfo)") 70 | } 71 | }) 72 | return container 73 | }() 74 | 75 | // MARK: - Core Data Saving support 76 | 77 | func saveContext () { 78 | let context = persistentContainer.viewContext 79 | if context.hasChanges { 80 | do { 81 | try context.save() 82 | } catch { 83 | // Replace this implementation with code to handle the error appropriately. 84 | // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. 85 | let nserror = error as NSError 86 | fatalError("Unresolved error \(nserror), \(nserror.userInfo)") 87 | } 88 | } 89 | } 90 | } 91 | 92 | 93 | -------------------------------------------------------------------------------- /Rates/ViewModels/CurrenciesViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CurrenciesViewModel.swift 3 | // Rates 4 | // 5 | // Created by Michael Henry Pantaleon on 2019/08/28. 6 | // Copyright © 2019 Michael Henry Pantaleon. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import CoreData 11 | 12 | class CurrenciesViewModel { 13 | 14 | var title:String 15 | private var isFetching:Bool = false 16 | 17 | private let api:APIService 18 | private var onReloadVisibleData:(() -> Void)? 19 | private var onError:((Error) -> Void)? 20 | 21 | private var fetchedResultsController: NSFetchedResultsController 22 | private let managedObjectContext:NSManagedObjectContext 23 | private var fetchResultDelegateWrapper:NSFetchedResultsControllerDelegateWrapper 24 | 25 | init( 26 | action: ActionType, 27 | api:APIService, 28 | managedObjectContext:NSManagedObjectContext, 29 | onWillChangeContent:(() -> Void)? = nil, 30 | onChange:(( 31 | _ indexPath:IndexPath?, 32 | _ type: NSFetchedResultsChangeType?, 33 | _ newIndexPath:IndexPath?) -> Void)? = nil, 34 | onDidChangeContent:(() -> Void)? = nil, 35 | onReloadVisibleData:(() -> Void)?, 36 | onError:((Error) -> Void)? = nil) { 37 | 38 | self.api = api 39 | self.managedObjectContext = managedObjectContext 40 | self.onReloadVisibleData = onReloadVisibleData 41 | self.onError = onError 42 | self.title = action.title 43 | 44 | fetchResultDelegateWrapper = NSFetchedResultsControllerDelegateWrapper( 45 | onWillChangeContent: onWillChangeContent, 46 | onDidChangeContent: onDidChangeContent, 47 | onChange: onChange) 48 | 49 | // Configure Fetch Request 50 | let fetchRequest: NSFetchRequest = Currency.fetchRequest() 51 | fetchedResultsController = NSFetchedResultsController( 52 | fetchRequest: fetchRequest, 53 | managedObjectContext: managedObjectContext, 54 | sectionNameKeyPath: nil, 55 | cacheName: nil) 56 | fetchedResultsController.delegate = fetchResultDelegateWrapper 57 | 58 | do { 59 | try fetchedResultsController.performFetch() 60 | } catch { 61 | self.onError?(error) 62 | } 63 | } 64 | 65 | func fetchCurrencies(_ completion: (() -> Void)? = nil) { 66 | if isFetching { return } 67 | isFetching = true 68 | api.fetchCurrencies() {[weak self] (result) in 69 | guard let weakSelf = self else { return } 70 | 71 | switch result { 72 | case .success(let value): 73 | let context = weakSelf.managedObjectContext 74 | context.mergePolicy = NSMergePolicy.overwrite 75 | context.perform { 76 | do { 77 | value.currencies.forEach { 78 | let c = Currency(ctx: context) 79 | c.code = $0.key 80 | c.name = $0.value 81 | } 82 | try context.save() 83 | } catch { 84 | if let onError = weakSelf.onError { 85 | DispatchQueue.main.async { 86 | onError(error) 87 | } 88 | } 89 | } 90 | } 91 | case .failure(let error): 92 | if let onError = weakSelf.onError { 93 | DispatchQueue.main.async { 94 | onError(error) 95 | } 96 | } 97 | } 98 | weakSelf.isFetching = false 99 | completion?() 100 | } 101 | } 102 | 103 | var numberOfItems:Int { 104 | return fetchedResultsController.fetchedObjects?.count ?? 0 105 | } 106 | 107 | var numberOfSections:Int { 108 | return 1 109 | } 110 | 111 | func currency(at indexPath:IndexPath) -> Currency? { 112 | let result:NSFetchRequestResult = fetchedResultsController.object(at: indexPath) 113 | return result as? Currency 114 | } 115 | } 116 | 117 | extension CurrenciesViewModel { 118 | 119 | /// Filtering the Currency list. 120 | /// 121 | /// - Parameters: 122 | /// - text 123 | /// - completion 124 | func filter(text: String = "") { 125 | fetchedResultsController.fetchRequest.predicate = NSPredicate(searchText: text) 126 | do { 127 | try fetchedResultsController.performFetch() 128 | onReloadVisibleData?() 129 | } catch { 130 | onError?(error) 131 | } 132 | } 133 | } 134 | 135 | extension CurrenciesViewModel { 136 | 137 | enum ActionType { 138 | case changeBaseCurrency 139 | case addNewCurrency 140 | 141 | var title:String { 142 | switch self { 143 | case .changeBaseCurrency: 144 | return "Select Base Currency" 145 | case .addNewCurrency: 146 | return "Select New Currency" 147 | } 148 | } 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /Rates/Views/CurrencyCell.xib: -------------------------------------------------------------------------------- 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 | 34 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /Rates.xcodeproj/xcshareddata/xcschemes/Rates.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 32 | 33 | 39 | 40 | 41 | 42 | 44 | 50 | 51 | 52 | 54 | 60 | 61 | 62 | 63 | 64 | 70 | 71 | 72 | 73 | 74 | 75 | 85 | 87 | 93 | 94 | 95 | 96 | 97 | 98 | 104 | 106 | 112 | 113 | 114 | 115 | 117 | 118 | 121 | 122 | 123 | -------------------------------------------------------------------------------- /RatesTests/RatesViewModelTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RatesViewModelTests.swift 3 | // RatesTests 4 | // 5 | // Created by Michael Henry Pantaleon on 2019/08/30. 6 | // Copyright © 2019 Michael Henry Pantaleon. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import CoreData 11 | @testable import Rates 12 | 13 | class RatesViewModelTests: XCTestCase { 14 | 15 | var viewModel:RatesViewModel! 16 | 17 | override func setUp() { 18 | URLProtocol.registerClass(RequestMocker.self) 19 | 20 | // For unit testing, We just need to store it in-memory 21 | let container = NSPersistentContainer(name: "Rates") 22 | let description = NSPersistentStoreDescription() 23 | description.type = NSInMemoryStoreType 24 | description.shouldAddStoreAsynchronously = false 25 | container.persistentStoreDescriptions = [description] 26 | container.loadPersistentStores { (description, error) in 27 | // Check if the data store is in memory 28 | precondition( description.type == NSInMemoryStoreType ) 29 | if let error = error { 30 | fatalError("Create an in-memory coordinator failed \(error)") 31 | } 32 | } 33 | 34 | let context = container.viewContext 35 | viewModel = RatesViewModel( 36 | api: APIService.shared, 37 | managedObjectContext: context, // Let's use in-memory persistent 38 | defaults: InMemoryAppDefaults(), 39 | newRequestWaitingTime: 5, // WAITING TIME FOR NEW REQUEST is 5 secs in Test 40 | onWillChangeContent: nil, 41 | onChange: nil, 42 | onDidChangeContent: nil, 43 | onReloadVisibleData: nil, 44 | onError: { error in 45 | fatalError(error.localizedDescription) 46 | }) 47 | } 48 | 49 | func testBasicInfo() { 50 | XCTAssertEqual(viewModel.title, "Rates") 51 | XCTAssertEqual(viewModel.numberOfSections, 1) 52 | XCTAssertEqual(viewModel.numberOfItems, 0) 53 | } 54 | 55 | func testActivateCodeAndUpdateTheReferenceValue() { 56 | 57 | // Let's fetch Data First. 58 | var ex = expectation(description: "wait to fetch data") 59 | viewModel.refresh() { hasExecuted in 60 | XCTAssertTrue(hasExecuted) 61 | ex.fulfill() 62 | } 63 | wait(for: [ex], timeout: 2.0) 64 | 65 | // Add JPY 66 | ex = expectation(description: "wait to activate JPY") 67 | viewModel.activate(code: "JPY") { 68 | ex.fulfill() 69 | } 70 | wait(for: [ex], timeout: 1.0) 71 | XCTAssertEqual(viewModel.numberOfItems, 1) 72 | 73 | // CHECK CONVERSION RATE OF 1.00 USD TO JPY 74 | var conversionRate = viewModel.item(at: indexPath(for: 0)) 75 | XCTAssertEqual(conversionRate?.currencyCode, "JPY") 76 | XCTAssertEqual(conversionRate?.value, 105.760985) 77 | 78 | // UPDATE THE BASE CURRENCY (USD) TO 2.00 79 | viewModel.update(referenceValue: 2.00) 80 | conversionRate = viewModel.item(at: indexPath(for: 0)) 81 | XCTAssertEqual(conversionRate?.value, 211.52197) 82 | 83 | // ADD NEW CODE ZWL 84 | ex = expectation(description: "wait to activate again a new code") 85 | viewModel.activate(code: "ZWL") { 86 | ex.fulfill() 87 | } 88 | wait(for: [ex], timeout: 1.0) 89 | XCTAssertEqual(viewModel.numberOfItems, 2) 90 | 91 | // CHANGE BACK THE BASE CURRENCY to 1.00 92 | viewModel.update(referenceValue: 1.00) 93 | conversionRate = viewModel.item(at: indexPath(for: 1)) 94 | XCTAssertEqual(conversionRate?.currencyCode, "ZWL") 95 | XCTAssertEqual(conversionRate?.value, 322.000001) 96 | 97 | // DELETE RATE AT INDEX 0, WHICH IS JPY (BECAUSE CODE SORTED ALPHABETICALLY) 98 | ex = expectation(description: "expect to delete a code from list") 99 | viewModel.delete(at: indexPath(for: 0)) { 100 | ex.fulfill() 101 | } 102 | wait(for: [ex], timeout: 1.0) 103 | 104 | XCTAssertEqual(viewModel.numberOfItems, 1) 105 | 106 | // CHECK IF THE FIRST ITEM NOW IS ZWL 107 | conversionRate = viewModel.item(at: indexPath(for: 0)) 108 | XCTAssertEqual(conversionRate?.currencyCode, "ZWL") 109 | XCTAssertEqual(conversionRate?.value, 322.000001) 110 | 111 | // DELETE THE LAST CODE 112 | ex = expectation(description: "expect to delete a code from list") 113 | viewModel.delete(at: indexPath(for: 0)) { 114 | ex.fulfill() 115 | } 116 | wait(for: [ex], timeout: 1.0) 117 | XCTAssertEqual(viewModel.numberOfItems, 0) 118 | } 119 | 120 | func testWaitingTimeToAvoidRateLimit() { 121 | var ex = expectation(description: "wait to fetch data") 122 | viewModel.refresh() { hasExecuted in 123 | ex.fulfill() 124 | } 125 | wait(for: [ex], timeout: 1.0) 126 | 127 | ex = expectation(description: "wait to fetch data again after 4 seconds but with hasExecuted will still FALSE") 128 | sleep(4) 129 | viewModel.refresh() { hasExecuted in 130 | XCTAssertFalse(hasExecuted) 131 | ex.fulfill() 132 | } 133 | wait(for: [ex], timeout: 1.0) 134 | 135 | ex = expectation(description: "wait again after 1 second and the hasExecuted should be TRUE") 136 | sleep(1) 137 | viewModel.refresh() { hasExecuted in 138 | XCTAssertTrue(hasExecuted) 139 | ex.fulfill() 140 | } 141 | wait(for: [ex], timeout: 1.0) 142 | } 143 | 144 | override func tearDown() { 145 | URLProtocol.unregisterClass(RequestMocker.self) 146 | } 147 | } 148 | 149 | 150 | // MARK: - Helpers 151 | extension RatesViewModelTests { 152 | 153 | fileprivate func indexPath(for index:Int) -> IndexPath { 154 | return IndexPath(row: index, section: 0) 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /Rates/ViewControllers/CurrenciesViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CurrenciesViewController.swift 3 | // Rates 4 | // 5 | // Created by Michael Henry Pantaleon on 2019/08/28. 6 | // Copyright © 2019 Michael Henry Pantaleon. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import CoreData 11 | 12 | protocol CurrenciesViewControllerDelegate:class { 13 | 14 | func currenciesVC(_ currenciesVC:CurrenciesViewController, didSelect currency:Currency) 15 | func currenciesVCDidCancel(_ currenciesVC:CurrenciesViewController) 16 | } 17 | 18 | class CurrenciesViewController:UITableViewController { 19 | 20 | var action:CurrenciesViewModel.ActionType! 21 | 22 | weak var delegate:CurrenciesViewControllerDelegate? 23 | 24 | lazy var viewModel:CurrenciesViewModel = { 25 | guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else { 26 | fatalError("AppDelegate must not be null!") 27 | } 28 | return CurrenciesViewModel( 29 | action: self.action, 30 | api: APIService.shared, 31 | managedObjectContext: appDelegate.persistentContainer.viewContext, 32 | onWillChangeContent: { [weak self] in 33 | self?.tableView.beginUpdates() 34 | }, 35 | onChange: { [weak self] (indexPath, type, newIndexPath) in 36 | guard let weakSelf = self, let _type = type else { return } 37 | switch (_type) { 38 | case .insert: 39 | guard let _newIndexPath = newIndexPath else { return } 40 | weakSelf.tableView.insertRows(at: [_newIndexPath], with: .none) 41 | case .update: 42 | guard let _indexPath = indexPath else { return } 43 | weakSelf.tableView.reloadRows(at: [_indexPath], with: .none) 44 | case .delete: 45 | guard let _indexPath = indexPath else { return } 46 | weakSelf.tableView.deleteRows(at: [_indexPath], with: .none) 47 | case .move: 48 | guard let _indexPath = indexPath, let _newIndexPath = newIndexPath else { return } 49 | weakSelf.tableView.moveRow(at: _indexPath, to: _newIndexPath) 50 | default: 51 | break 52 | } 53 | }, 54 | onDidChangeContent: { [weak self] in 55 | self?.tableView.endUpdates() 56 | }, 57 | onReloadVisibleData: { [weak self] in 58 | self?.tableView.reloadData() 59 | }, 60 | onError: {[weak self] error in 61 | guard let weakSelf = self else { return } 62 | weakSelf.showAlert( 63 | title: "Error", 64 | message: "\(error)", 65 | actions: [ 66 | UIAlertAction(title: "Cancel", style: .cancel, handler: nil), 67 | UIAlertAction( 68 | title: "Retry", 69 | style: .default, 70 | handler: { (action) in 71 | weakSelf.viewModel.fetchCurrencies() 72 | }), 73 | ]) 74 | }) 75 | }() 76 | 77 | override func viewDidLoad() { 78 | super.viewDidLoad() 79 | title = action.title 80 | tableView.rowHeight = UITableView.automaticDimension 81 | tableView.register(CurrencyCell.self) 82 | tableView.dataSource = self 83 | tableView.delegate = self 84 | tableView.keyboardDismissMode = .onDrag 85 | 86 | let searchController = UISearchController(searchResultsController: nil) 87 | searchController.obscuresBackgroundDuringPresentation = false 88 | searchController.searchBar.placeholder = "Search Currencies" 89 | searchController.searchBar.delegate = self 90 | navigationItem.searchController = searchController 91 | definesPresentationContext = true 92 | 93 | navigationItem.rightBarButtonItem = UIBarButtonItem( 94 | title: "Cancel", 95 | style: .plain, 96 | target: self, 97 | action: #selector(CurrenciesViewController.didCancel)) 98 | 99 | // TODO Able to Filter/Exclude the Currencies that have already selected 100 | 101 | if viewModel.numberOfItems == 0 { 102 | // This must rarely update, so for now let's call this only once. 103 | viewModel.fetchCurrencies() 104 | } 105 | } 106 | 107 | override func numberOfSections(in tableView: UITableView) -> Int { 108 | return viewModel.numberOfSections 109 | } 110 | 111 | override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 112 | return viewModel.numberOfItems 113 | } 114 | 115 | override func tableView( 116 | _ tableView: UITableView, 117 | cellForRowAt indexPath: IndexPath) -> UITableViewCell { 118 | 119 | let cell:CurrencyCell = tableView.dequeue(CurrencyCell.self)! 120 | guard let currency = viewModel.currency(at: indexPath) 121 | else { return UITableViewCell() } 122 | cell.bind(currency) 123 | return cell 124 | } 125 | 126 | override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 127 | tableView.deselectRow(at: indexPath, animated: true) 128 | if let currency = viewModel.currency(at: indexPath) { 129 | delegate?.currenciesVC(self, didSelect: currency) 130 | } 131 | } 132 | } 133 | 134 | // MARK: - UISearchBarDelegate 135 | extension CurrenciesViewController:UISearchBarDelegate { 136 | 137 | func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { 138 | viewModel.filter(text: searchText) 139 | } 140 | } 141 | 142 | // MARK: - Extra Actions Done By User 143 | extension CurrenciesViewController { 144 | 145 | @objc func didCancel() { 146 | delegate?.currenciesVCDidCancel(self) 147 | } 148 | } 149 | 150 | // MARK: - AlertShowable 151 | extension CurrenciesViewController:AlertShowable {} 152 | -------------------------------------------------------------------------------- /Rates/Views/RateCell.xib: -------------------------------------------------------------------------------- 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 | 38 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /Rates/ViewControllers/RatesViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RatesViewController.swift 3 | // Rates 4 | // 5 | // Created by Michael Henry Pantaleon on 2019/08/28. 6 | // Copyright © 2019 Michael Henry Pantaleon. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import CoreData 11 | 12 | class RatesViewController:UIViewController { 13 | 14 | @IBOutlet weak var tableView:UITableView! 15 | @IBOutlet weak var inputField:UITextField! 16 | @IBOutlet weak var currencyButton:UIButton! 17 | @IBOutlet weak var lastUpdatedLabel:UILabel! 18 | 19 | lazy var viewModel:RatesViewModel = { 20 | guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else { 21 | fatalError("AppDelegate must not be null!") 22 | } 23 | return RatesViewModel( 24 | api: APIService.shared, 25 | managedObjectContext: appDelegate.persistentContainer.viewContext, 26 | defaults: AppDefaults.shared, 27 | onDidReceiveUpdatedData: {[weak self] in 28 | self?.currencyButton.setTitle(self?.viewModel.baseCurrencyCode(), for: .normal) 29 | self?.lastUpdatedLabel.text = self?.viewModel.lastQuotesTimestampText() 30 | }, 31 | onWillChangeContent: { [weak self] in 32 | self?.tableView.beginUpdates() 33 | }, 34 | onChange: { [weak self] (indexPath, type, newIndexPath) in 35 | guard let weakSelf = self, let _type = type else { return } 36 | switch (_type) { 37 | case .insert: 38 | guard let _newIndexPath = newIndexPath else { return } 39 | weakSelf.tableView.insertRows(at: [_newIndexPath], with: .fade) 40 | case .update: 41 | guard let _indexPath = indexPath else { return } 42 | weakSelf.tableView.reloadRows(at: [_indexPath], with: .fade) 43 | case .delete: 44 | guard let _indexPath = indexPath else { return } 45 | weakSelf.tableView.deleteRows(at: [_indexPath], with: .fade) 46 | case .move: 47 | guard let _indexPath = indexPath, let _newIndexPath = newIndexPath else { return } 48 | weakSelf.tableView.moveRow(at: _indexPath, to: _newIndexPath) 49 | default: 50 | break 51 | } 52 | }, 53 | onDidChangeContent: { [weak self] in 54 | self?.tableView.endUpdates() 55 | }, 56 | onReloadVisibleData: { [weak self] in 57 | self?.tableView.reloadData() 58 | }, 59 | onError: {[weak self] error in 60 | guard let weakSelf = self else { return } 61 | weakSelf 62 | .showAlert( 63 | title: "Error", 64 | message: "\(error)", 65 | actions: [ 66 | UIAlertAction(title: "Cancel", style: .cancel, handler: nil), 67 | UIAlertAction( 68 | title: "Retry", 69 | style: .default, 70 | handler: { (action) in 71 | weakSelf.viewModel.retry() 72 | }), 73 | ]) 74 | }) 75 | }() 76 | 77 | override func viewDidLoad() { 78 | super.viewDidLoad() 79 | 80 | title = viewModel.title 81 | 82 | let refreshControl = UIRefreshControl() 83 | refreshControl.addTarget( 84 | self, action: #selector(RatesViewController.refresh(_:)), 85 | for: .valueChanged) 86 | 87 | tableView.rowHeight = 70 88 | tableView.register(RateCell.self) 89 | tableView.dataSource = self 90 | tableView.delegate = self 91 | tableView.separatorStyle = .none 92 | tableView.keyboardDismissMode = .onDrag 93 | tableView.refreshControl = refreshControl 94 | 95 | navigationItem.rightBarButtonItem = UIBarButtonItem( 96 | barButtonSystemItem: .add, 97 | target: self, action: #selector(RatesViewController.showCurrencies)) 98 | 99 | currencyButton.setTitle(viewModel.baseCurrencyCode(), for: .normal) 100 | currencyButton.layer.cornerRadius = 10.0 101 | currencyButton.layer.borderWidth = 1.0 102 | currencyButton.layer.borderColor = UIColor.black.cgColor 103 | currencyButton.addTarget( 104 | self, action: #selector(RatesViewController.updateBaseCurrency), 105 | for: .touchUpInside) 106 | 107 | inputField.text = "1.00" 108 | inputField.keyboardType = .decimalPad 109 | inputField.addTarget(self, action: #selector(RatesViewController.textFieldDidChange(_:)), for: .editingChanged) 110 | 111 | refresh() 112 | } 113 | } 114 | 115 | // MARK: - Actions that can be done by the User 116 | extension RatesViewController { 117 | 118 | @objc func textFieldDidChange(_ textField:UITextField) { 119 | guard let text = textField.text else { return } 120 | viewModel.update(referenceValue: Decimal(string: text) ?? 0.0) 121 | } 122 | 123 | @objc func refresh(_ sender: UIRefreshControl? = nil) { 124 | viewModel.refresh() { _ in 125 | sender?.endRefreshing() 126 | } 127 | } 128 | 129 | @objc func showCurrencies() { 130 | let currenciesVC = CurrenciesViewController() 131 | currenciesVC.action = .addNewCurrency 132 | currenciesVC.delegate = self 133 | present(UINavigationController(rootViewController: currenciesVC), 134 | animated: true, completion: nil) 135 | } 136 | 137 | @objc func updateBaseCurrency() { 138 | let currenciesVC = CurrenciesViewController() 139 | currenciesVC.action = .changeBaseCurrency 140 | currenciesVC.delegate = self 141 | present(UINavigationController(rootViewController: currenciesVC), 142 | animated: true, completion: nil) 143 | } 144 | } 145 | 146 | // MARK: - UITableViewDataSource 147 | extension RatesViewController:UITableViewDataSource { 148 | 149 | func numberOfSections(in tableView: UITableView) -> Int { 150 | return viewModel.numberOfSections 151 | } 152 | 153 | func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 154 | return viewModel.numberOfItems 155 | } 156 | 157 | func tableView( 158 | _ tableView: UITableView, 159 | cellForRowAt indexPath: IndexPath) -> UITableViewCell { 160 | 161 | let cell:RateCell = tableView.dequeue(RateCell.self)! 162 | guard let rate = viewModel.item(at: indexPath) 163 | else { return UITableViewCell() } 164 | cell.bind(rate) 165 | return cell 166 | } 167 | } 168 | 169 | // MARK: - UITableViewDelegate 170 | extension RatesViewController:UITableViewDelegate { 171 | 172 | func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) { 173 | switch editingStyle { 174 | case .delete: 175 | viewModel.delete(at: indexPath) 176 | default: 177 | break 178 | } 179 | } 180 | } 181 | 182 | // MARK: - CurrenciesViewControllerDelegate 183 | extension RatesViewController:CurrenciesViewControllerDelegate { 184 | func currenciesVCDidCancel(_ currenciesVC: CurrenciesViewController) { 185 | currenciesVC.dismiss(animated: true, completion: nil) 186 | } 187 | 188 | func currenciesVC(_ currenciesVC: CurrenciesViewController, didSelect currency: Currency) { 189 | guard let _code = currency.code, let action = currenciesVC.action else { return } 190 | 191 | currenciesVC.navigationController?.dismiss(animated: true) {[weak self] in 192 | switch action { 193 | case .addNewCurrency: 194 | self?.viewModel.activate(code: _code) 195 | case .changeBaseCurrency: 196 | self?.viewModel.update(baseCurrencyCode: _code) 197 | } 198 | } 199 | } 200 | } 201 | 202 | // MARK: - Make this ViewController AlertShowable 203 | extension RatesViewController:AlertShowable {} 204 | -------------------------------------------------------------------------------- /Rates/ViewModels/RatesViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RatesViewModel.swift 3 | // Rates 4 | // 5 | // Created by Michael Henry Pantaleon on 2019/08/28. 6 | // Copyright © 2019 Michael Henry Pantaleon. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import CoreData 11 | 12 | class RatesViewModel { 13 | 14 | let title = "Rates" 15 | 16 | private var isFetching:Bool = false 17 | private var newRequestWaitingTime:Double // In seconds 18 | 19 | private let api:APIService 20 | private var onError:((Error) -> Void)? 21 | private var onReloadVisibleData:(() -> Void)? 22 | private var onDidReceiveUpdatedData:(() -> Void)? 23 | 24 | private var fetchedResultsController: NSFetchedResultsController 25 | private let managedObjectContext:NSManagedObjectContext 26 | private let defaults:AppDefaultsConvertible 27 | 28 | private var fetchResultDelegateWrapper:NSFetchedResultsControllerDelegateWrapper 29 | 30 | private var referenceValue:Decimal = 1.0 31 | private let defaultCurrencies = ["JPY", "AUD", "EUR", "NZD", "SGD"] 32 | 33 | private var lastTriedCodeToExecute:String? = nil // For retry() 34 | 35 | init( 36 | api:APIService, 37 | managedObjectContext:NSManagedObjectContext, 38 | defaults:AppDefaultsConvertible, 39 | newRequestWaitingTime:Double = 30 * 60, // Default is 30 Minutes 40 | onDidReceiveUpdatedData:(() -> Void)? = nil, // Data comes from server 41 | onWillChangeContent:(() -> Void)? = nil, 42 | onChange:(( 43 | _ indexPath:IndexPath?, 44 | _ type: NSFetchedResultsChangeType?, 45 | _ newIndexPath:IndexPath?) -> Void)? = nil, 46 | onDidChangeContent:(() -> Void)? = nil, 47 | onReloadVisibleData:(() -> Void)? = nil, 48 | onError:((Error) -> Void)? = nil) { 49 | 50 | self.api = api 51 | self.managedObjectContext = managedObjectContext 52 | self.defaults = defaults 53 | self.newRequestWaitingTime = newRequestWaitingTime 54 | self.onReloadVisibleData = onReloadVisibleData 55 | self.onDidReceiveUpdatedData = onDidReceiveUpdatedData 56 | self.onError = onError 57 | 58 | fetchResultDelegateWrapper = NSFetchedResultsControllerDelegateWrapper( 59 | onWillChangeContent: onWillChangeContent, 60 | onDidChangeContent: onDidChangeContent, 61 | onChange: onChange) 62 | 63 | // Configure Fetch Request 64 | let fetchRequest: NSFetchRequest = Rate.fetchRequest() 65 | 66 | fetchedResultsController = NSFetchedResultsController( 67 | fetchRequest: fetchRequest, 68 | managedObjectContext: managedObjectContext, 69 | sectionNameKeyPath: nil, 70 | cacheName: nil) 71 | 72 | fetchedResultsController.delegate = fetchResultDelegateWrapper 73 | do { 74 | try fetchedResultsController.performFetch() 75 | } catch { 76 | self.onError?(error) 77 | } 78 | } 79 | 80 | 81 | /// Refresh Decides if the request will execute or not. 82 | /// 83 | /// - Parameter completion 84 | func refresh(_ completion: ((_ hasExcuted: Bool) -> Void)? = nil) { 85 | // check for the lastFetchTimestamp. 86 | if let _lastFetch = defaults.get(for: .lastFetchTimestamp) as Date?, Date().timeIntervalSince(_lastFetch) < newRequestWaitingTime { 87 | // the request was not executed, because we avoid to reach 88 | // the request rate limitation. 89 | completion?(false) 90 | return 91 | } 92 | 93 | fetchRates(code: baseCurrencyCode()) { 94 | DispatchQueue.main.async { 95 | completion?(true) 96 | } 97 | } 98 | } 99 | 100 | /// Retry to fetchRates request() 101 | func retry() { 102 | guard let code = lastTriedCodeToExecute else { return } 103 | fetchRates(code: code) 104 | } 105 | 106 | /// Fetch The Rate of certain code. 107 | /// 108 | /// - Parameters: 109 | /// - code 110 | /// - completion 111 | func fetchRates(code:String, _ completion: (() -> Void)? = nil) { 112 | 113 | if isFetching { return } 114 | isFetching = true 115 | lastTriedCodeToExecute = code 116 | api.fetchLive(source: code) {[weak self] (result) in 117 | guard let weakSelf = self else { return } 118 | 119 | switch result { 120 | case .success(let value): 121 | 122 | // Delete the lastTriedCode, since we don't need to retry() the request. 123 | weakSelf.lastTriedCodeToExecute = nil 124 | 125 | // Let's check if it has previous data, so we can add a default value if none. 126 | let needToAddDefaultCurrencyList = weakSelf.defaults.get(for: .lastQuotesTimestamp) == nil 127 | 128 | // Must set to value.timestamp for the lastQuotesTimestamp 129 | weakSelf.defaults.set(value: value.timestamp, for: .lastQuotesTimestamp) 130 | 131 | // Let's set the actual base currency now, since we finally get a valid one. 132 | weakSelf.defaults.set(value: value.source, for: .baseCurrencyCode) 133 | if let onDidReceiveUpdatedData = weakSelf.onDidReceiveUpdatedData { 134 | DispatchQueue.main.async { 135 | onDidReceiveUpdatedData() 136 | } 137 | } 138 | 139 | let context = weakSelf.managedObjectContext 140 | 141 | context.mergePolicy = NSMergePolicy.mergeByPropertyStoreTrump 142 | context.perform { 143 | do { 144 | value.quotes.forEach { 145 | let currencyCode = String($0.key.suffix(3)) 146 | let r = Rate.findOrCreate(currencyCode: currencyCode, in: context) 147 | // code will be like Source-Target like `USDGBP` 148 | // so let's clean the data before saving to our database since we only support 1 base currency conversion at a time 149 | r.currencyCode = String($0.key.suffix(3)) 150 | r.value = NSDecimalNumber(floatLiteral: $0.value) 151 | if needToAddDefaultCurrencyList, weakSelf.defaultCurrencies.contains(currencyCode) { 152 | r.active = true 153 | } 154 | } 155 | try context.save() 156 | } catch { 157 | if let onError = weakSelf.onError { 158 | DispatchQueue.main.async { 159 | onError(error) 160 | } 161 | } 162 | } 163 | } 164 | 165 | // Must set to now the lastFetchtimestamp 166 | weakSelf.defaults.set(value: Date(), for: .lastFetchTimestamp) 167 | 168 | case .failure(let error): 169 | if let onError = weakSelf.onError { 170 | DispatchQueue.main.async { 171 | onError(error) 172 | } 173 | } 174 | } 175 | weakSelf.isFetching = false 176 | completion?() 177 | } 178 | } 179 | 180 | var numberOfItems:Int { 181 | return fetchedResultsController.fetchedObjects?.count ?? 0 182 | } 183 | 184 | var numberOfSections:Int { 185 | return 1 186 | } 187 | 188 | /// The Rate at indexPath. 189 | /// 190 | /// - Parameter indexPath 191 | /// - Returns Rate 192 | func rate(at indexPath:IndexPath) -> Rate? { 193 | let result:NSFetchRequestResult = fetchedResultsController.object(at: indexPath) 194 | return result as? Rate 195 | } 196 | 197 | /// Delete an item from your listing. 198 | /// 199 | /// - Parameters: 200 | /// - indexPath: 201 | /// - completion: 202 | func delete(at indexPath:IndexPath, completion: (() -> Void)? = nil) { 203 | let result:NSFetchRequestResult = fetchedResultsController.object(at: indexPath) 204 | managedObjectContext.perform { [weak self] in 205 | do { 206 | (result as! Rate).active = false 207 | try self?.managedObjectContext.save() 208 | completion?() 209 | } catch { 210 | if let onError = self?.onError { 211 | DispatchQueue.main.async { 212 | onError(error) 213 | } 214 | } 215 | } 216 | } 217 | } 218 | 219 | /// Activate a certain currency that will display to your listing. 220 | /// 221 | /// - Parameters: 222 | /// - code: 223 | /// - completion: 224 | func activate(code:String, completion: (() -> Void)? = nil) { 225 | managedObjectContext.perform { [weak self] in 226 | do { 227 | guard let context = self?.managedObjectContext, 228 | let rate = Rate.find(currencyCode: code, in: context) 229 | else { return } 230 | rate.active = true 231 | try context.save() 232 | completion?() 233 | } catch { 234 | if let onError = self?.onError { 235 | DispatchQueue.main.async { 236 | onError(error) 237 | } 238 | } 239 | } 240 | } 241 | } 242 | 243 | /// Get the Equivalent Rate of the Base Currency in other currencies. 244 | /// 245 | /// - Parameter indexPath 246 | /// - Returns: EquivalentRate? 247 | func item(at indexPath:IndexPath) -> EquivalentRate? { 248 | guard let obj = rate(at: indexPath) else { return nil } 249 | return obj.equivalentRate(at: NSDecimalNumber(decimal: referenceValue)) 250 | } 251 | 252 | /// lastQuotesTimestampText() 253 | /// 254 | /// - Returns: is the `As of` timestamp from the API 255 | func lastQuotesTimestampText() -> String { 256 | guard let _lastUpdate:Date = defaults.get(for: .lastQuotesTimestamp) 257 | else { return "" } 258 | let formatter = DateFormatter() 259 | formatter.dateFormat = "MMM dd yyyy HH:mm a" 260 | return "As of \(formatter.string(from: _lastUpdate))" 261 | } 262 | 263 | /// Get the Base Currency Code 264 | /// 265 | /// - Returns: String 266 | func baseCurrencyCode() -> String { 267 | return defaults.get(for: .baseCurrencyCode) ?? "USD" 268 | } 269 | } 270 | 271 | // MARK: - Other Actions 272 | extension RatesViewModel { 273 | 274 | /// Update the Reference value (of the Base currency) 275 | /// 276 | /// - Parameter referenceValue 277 | func update(referenceValue: Decimal) { 278 | self.referenceValue = referenceValue 279 | // No need to hit the server we just have to update the visible data. 280 | onReloadVisibleData?() 281 | } 282 | 283 | /// Update the Base Currency Code 284 | /// 285 | /// - Parameter baseCurrencyCode 286 | func update(baseCurrencyCode: String) { 287 | // Need to update the data from server, 288 | fetchRates(code: baseCurrencyCode) 289 | } 290 | } 291 | -------------------------------------------------------------------------------- /Rates/Views/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 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 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 | -------------------------------------------------------------------------------- /Rates.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 50; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | A64AA87D23180126008FB8C0 /* RequestMocker.swift in Sources */ = {isa = PBXBuildFile; fileRef = A64AA8712317FA81008FB8C0 /* RequestMocker.swift */; }; 11 | A64AA87F23180534008FB8C0 /* mock-resources in Resources */ = {isa = PBXBuildFile; fileRef = A64AA87E23180534008FB8C0 /* mock-resources */; }; 12 | A64AA881231837DE008FB8C0 /* RatesViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A64AA880231837DE008FB8C0 /* RatesViewModelTests.swift */; }; 13 | A655220D23178BE200852430 /* RequestError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A655220C23178BE200852430 /* RequestError.swift */; }; 14 | A655220F23178CB300852430 /* AlertShowable.swift in Sources */ = {isa = PBXBuildFile; fileRef = A655220E23178CB300852430 /* AlertShowable.swift */; }; 15 | A655221123179F0E00852430 /* ConvertedRate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A655221023179F0E00852430 /* ConvertedRate.swift */; }; 16 | A6606DC023161B1000F25DCC /* Currencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6606DBF23161B1000F25DCC /* Currencies.swift */; }; 17 | A6606DC223161CE500F25DCC /* Quotes.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6606DC123161CE500F25DCC /* Quotes.swift */; }; 18 | A6606DC52316278800F25DCC /* CurrenciesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6606DC42316278800F25DCC /* CurrenciesViewModel.swift */; }; 19 | A6606DD423162AF700F25DCC /* Currency+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6606DD023162AF700F25DCC /* Currency+CoreDataClass.swift */; }; 20 | A6606DD523162AF700F25DCC /* Currency+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6606DD123162AF700F25DCC /* Currency+CoreDataProperties.swift */; }; 21 | A6606DD82316313500F25DCC /* CurrenciesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6606DD72316313500F25DCC /* CurrenciesViewController.swift */; }; 22 | A6606DDA2316325800F25DCC /* NibLoadable.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6606DD92316325800F25DCC /* NibLoadable.swift */; }; 23 | A6606DDC2316327B00F25DCC /* Reusable.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6606DDB2316327B00F25DCC /* Reusable.swift */; }; 24 | A6606DDE231632AC00F25DCC /* UITableView_Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6606DDD231632AC00F25DCC /* UITableView_Extensions.swift */; }; 25 | A6606DE0231632CE00F25DCC /* Bindable.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6606DDF231632CE00F25DCC /* Bindable.swift */; }; 26 | A6606DE22316331F00F25DCC /* UITableViewCell_Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6606DE12316331F00F25DCC /* UITableViewCell_Extensions.swift */; }; 27 | A6606DE62316334300F25DCC /* CurrencyCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6606DE42316334300F25DCC /* CurrencyCell.swift */; }; 28 | A6606DE72316334300F25DCC /* CurrencyCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = A6606DE52316334300F25DCC /* CurrencyCell.xib */; }; 29 | A6606DE923163CBC00F25DCC /* RequestCaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6606DE823163CBC00F25DCC /* RequestCaller.swift */; }; 30 | A6606DED23164CCD00F25DCC /* RatesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6606DEC23164CCD00F25DCC /* RatesViewModel.swift */; }; 31 | A6606DEF23164E5F00F25DCC /* RatesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6606DEE23164E5F00F25DCC /* RatesViewController.swift */; }; 32 | A6606DF223164EBB00F25DCC /* RateCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6606DF023164EBB00F25DCC /* RateCell.swift */; }; 33 | A6606DF323164EBB00F25DCC /* RateCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = A6606DF123164EBB00F25DCC /* RateCell.xib */; }; 34 | A6606DF52316777D00F25DCC /* NSFetchResultControllerDelegateWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6606DF42316777D00F25DCC /* NSFetchResultControllerDelegateWrapper.swift */; }; 35 | A66194E12318E83A00535708 /* AppDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = A66194E02318E83A00535708 /* AppDefaults.swift */; }; 36 | A66194E32318EA9900535708 /* AppDefaultsConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = A66194E22318EA9900535708 /* AppDefaultsConvertible.swift */; }; 37 | A66194E52318F08F00535708 /* InMemoryAppDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = A66194E42318F08F00535708 /* InMemoryAppDefaults.swift */; }; 38 | A6B70654231BA9B3009A4596 /* NSManagedObject_Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6B70653231BA9B3009A4596 /* NSManagedObject_Extensions.swift */; }; 39 | A6DCB3FE23158D3500FAF71F /* ErrorInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6DCB3FD23158D3500FAF71F /* ErrorInfo.swift */; }; 40 | A6E189D12315B0F6009F9DCE /* APIServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E189D02315B0F6009F9DCE /* APIServiceTests.swift */; }; 41 | A6E189DA2316E1C4009F9DCE /* Rate+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E189D82316E1C4009F9DCE /* Rate+CoreDataClass.swift */; }; 42 | A6E189DB2316E1C4009F9DCE /* Rate+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E189D92316E1C4009F9DCE /* Rate+CoreDataProperties.swift */; }; 43 | A6F19C29231567CD00DB8B17 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6F19C28231567CD00DB8B17 /* AppDelegate.swift */; }; 44 | A6F19C2E231567CD00DB8B17 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = A6F19C2C231567CD00DB8B17 /* Main.storyboard */; }; 45 | A6F19C31231567CD00DB8B17 /* Rates.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = A6F19C2F231567CD00DB8B17 /* Rates.xcdatamodeld */; }; 46 | A6F19C33231567CF00DB8B17 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A6F19C32231567CF00DB8B17 /* Assets.xcassets */; }; 47 | A6F19C36231567CF00DB8B17 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = A6F19C34231567CF00DB8B17 /* LaunchScreen.storyboard */; }; 48 | A6F19C4C231567CF00DB8B17 /* RatesUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6F19C4B231567CF00DB8B17 /* RatesUITests.swift */; }; 49 | A6F19C66231576C300DB8B17 /* APIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6F19C65231576C300DB8B17 /* APIService.swift */; }; 50 | /* End PBXBuildFile section */ 51 | 52 | /* Begin PBXContainerItemProxy section */ 53 | A6F19C3D231567CF00DB8B17 /* PBXContainerItemProxy */ = { 54 | isa = PBXContainerItemProxy; 55 | containerPortal = A6F19C1D231567CD00DB8B17 /* Project object */; 56 | proxyType = 1; 57 | remoteGlobalIDString = A6F19C24231567CD00DB8B17; 58 | remoteInfo = Rates; 59 | }; 60 | A6F19C48231567CF00DB8B17 /* PBXContainerItemProxy */ = { 61 | isa = PBXContainerItemProxy; 62 | containerPortal = A6F19C1D231567CD00DB8B17 /* Project object */; 63 | proxyType = 1; 64 | remoteGlobalIDString = A6F19C24231567CD00DB8B17; 65 | remoteInfo = Rates; 66 | }; 67 | /* End PBXContainerItemProxy section */ 68 | 69 | /* Begin PBXFileReference section */ 70 | A64AA8712317FA81008FB8C0 /* RequestMocker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestMocker.swift; sourceTree = ""; }; 71 | A64AA87E23180534008FB8C0 /* mock-resources */ = {isa = PBXFileReference; lastKnownFileType = folder; path = "mock-resources"; sourceTree = SOURCE_ROOT; }; 72 | A64AA880231837DE008FB8C0 /* RatesViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RatesViewModelTests.swift; sourceTree = ""; }; 73 | A655220C23178BE200852430 /* RequestError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestError.swift; sourceTree = ""; }; 74 | A655220E23178CB300852430 /* AlertShowable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertShowable.swift; sourceTree = ""; }; 75 | A655221023179F0E00852430 /* ConvertedRate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConvertedRate.swift; sourceTree = ""; }; 76 | A6606DBF23161B1000F25DCC /* Currencies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Currencies.swift; sourceTree = ""; }; 77 | A6606DC123161CE500F25DCC /* Quotes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Quotes.swift; sourceTree = ""; }; 78 | A6606DC42316278800F25DCC /* CurrenciesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrenciesViewModel.swift; sourceTree = ""; }; 79 | A6606DD023162AF700F25DCC /* Currency+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Currency+CoreDataClass.swift"; sourceTree = ""; }; 80 | A6606DD123162AF700F25DCC /* Currency+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Currency+CoreDataProperties.swift"; sourceTree = ""; }; 81 | A6606DD72316313500F25DCC /* CurrenciesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrenciesViewController.swift; sourceTree = ""; }; 82 | A6606DD92316325800F25DCC /* NibLoadable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NibLoadable.swift; sourceTree = ""; }; 83 | A6606DDB2316327B00F25DCC /* Reusable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Reusable.swift; sourceTree = ""; }; 84 | A6606DDD231632AC00F25DCC /* UITableView_Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITableView_Extensions.swift; sourceTree = ""; }; 85 | A6606DDF231632CE00F25DCC /* Bindable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bindable.swift; sourceTree = ""; }; 86 | A6606DE12316331F00F25DCC /* UITableViewCell_Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITableViewCell_Extensions.swift; sourceTree = ""; }; 87 | A6606DE42316334300F25DCC /* CurrencyCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrencyCell.swift; sourceTree = ""; }; 88 | A6606DE52316334300F25DCC /* CurrencyCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = CurrencyCell.xib; sourceTree = ""; }; 89 | A6606DE823163CBC00F25DCC /* RequestCaller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestCaller.swift; sourceTree = ""; }; 90 | A6606DEC23164CCD00F25DCC /* RatesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RatesViewModel.swift; sourceTree = ""; }; 91 | A6606DEE23164E5F00F25DCC /* RatesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RatesViewController.swift; sourceTree = ""; }; 92 | A6606DF023164EBB00F25DCC /* RateCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RateCell.swift; sourceTree = ""; }; 93 | A6606DF123164EBB00F25DCC /* RateCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = RateCell.xib; sourceTree = ""; }; 94 | A6606DF42316777D00F25DCC /* NSFetchResultControllerDelegateWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSFetchResultControllerDelegateWrapper.swift; sourceTree = ""; }; 95 | A66194E02318E83A00535708 /* AppDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDefaults.swift; sourceTree = ""; }; 96 | A66194E22318EA9900535708 /* AppDefaultsConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDefaultsConvertible.swift; sourceTree = ""; }; 97 | A66194E42318F08F00535708 /* InMemoryAppDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InMemoryAppDefaults.swift; sourceTree = ""; }; 98 | A6B70653231BA9B3009A4596 /* NSManagedObject_Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSManagedObject_Extensions.swift; sourceTree = ""; }; 99 | A6DCB3FD23158D3500FAF71F /* ErrorInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorInfo.swift; sourceTree = ""; }; 100 | A6E189D02315B0F6009F9DCE /* APIServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIServiceTests.swift; sourceTree = ""; }; 101 | A6E189D82316E1C4009F9DCE /* Rate+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Rate+CoreDataClass.swift"; sourceTree = ""; }; 102 | A6E189D92316E1C4009F9DCE /* Rate+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Rate+CoreDataProperties.swift"; sourceTree = ""; }; 103 | A6F19C25231567CD00DB8B17 /* Rates.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Rates.app; sourceTree = BUILT_PRODUCTS_DIR; }; 104 | A6F19C28231567CD00DB8B17 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 105 | A6F19C2D231567CD00DB8B17 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 106 | A6F19C30231567CD00DB8B17 /* Rates.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Rates.xcdatamodel; sourceTree = ""; }; 107 | A6F19C32231567CF00DB8B17 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 108 | A6F19C35231567CF00DB8B17 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 109 | A6F19C37231567CF00DB8B17 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 110 | A6F19C3C231567CF00DB8B17 /* RatesTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RatesTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 111 | A6F19C42231567CF00DB8B17 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 112 | A6F19C47231567CF00DB8B17 /* RatesUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RatesUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 113 | A6F19C4B231567CF00DB8B17 /* RatesUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RatesUITests.swift; sourceTree = ""; }; 114 | A6F19C4D231567CF00DB8B17 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 115 | A6F19C65231576C300DB8B17 /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = ""; }; 116 | /* End PBXFileReference section */ 117 | 118 | /* Begin PBXFrameworksBuildPhase section */ 119 | A6F19C22231567CD00DB8B17 /* Frameworks */ = { 120 | isa = PBXFrameworksBuildPhase; 121 | buildActionMask = 2147483647; 122 | files = ( 123 | ); 124 | runOnlyForDeploymentPostprocessing = 0; 125 | }; 126 | A6F19C39231567CF00DB8B17 /* Frameworks */ = { 127 | isa = PBXFrameworksBuildPhase; 128 | buildActionMask = 2147483647; 129 | files = ( 130 | ); 131 | runOnlyForDeploymentPostprocessing = 0; 132 | }; 133 | A6F19C44231567CF00DB8B17 /* Frameworks */ = { 134 | isa = PBXFrameworksBuildPhase; 135 | buildActionMask = 2147483647; 136 | files = ( 137 | ); 138 | runOnlyForDeploymentPostprocessing = 0; 139 | }; 140 | /* End PBXFrameworksBuildPhase section */ 141 | 142 | /* Begin PBXGroup section */ 143 | A6606DBA23161A9200F25DCC /* Protocols */ = { 144 | isa = PBXGroup; 145 | children = ( 146 | A6606DD92316325800F25DCC /* NibLoadable.swift */, 147 | A6606DDB2316327B00F25DCC /* Reusable.swift */, 148 | A6606DDF231632CE00F25DCC /* Bindable.swift */, 149 | A655220E23178CB300852430 /* AlertShowable.swift */, 150 | A66194E22318EA9900535708 /* AppDefaultsConvertible.swift */, 151 | ); 152 | path = Protocols; 153 | sourceTree = ""; 154 | }; 155 | A6606DC32316277C00F25DCC /* ViewModels */ = { 156 | isa = PBXGroup; 157 | children = ( 158 | A6606DC42316278800F25DCC /* CurrenciesViewModel.swift */, 159 | A6606DEC23164CCD00F25DCC /* RatesViewModel.swift */, 160 | ); 161 | path = ViewModels; 162 | sourceTree = ""; 163 | }; 164 | A6606DD623162C3800F25DCC /* ViewControllers */ = { 165 | isa = PBXGroup; 166 | children = ( 167 | A6606DD72316313500F25DCC /* CurrenciesViewController.swift */, 168 | A6606DEE23164E5F00F25DCC /* RatesViewController.swift */, 169 | ); 170 | path = ViewControllers; 171 | sourceTree = ""; 172 | }; 173 | A6606DE32316332D00F25DCC /* Views */ = { 174 | isa = PBXGroup; 175 | children = ( 176 | A6F19C2C231567CD00DB8B17 /* Main.storyboard */, 177 | A6606DE42316334300F25DCC /* CurrencyCell.swift */, 178 | A6606DE52316334300F25DCC /* CurrencyCell.xib */, 179 | A6606DF023164EBB00F25DCC /* RateCell.swift */, 180 | A6606DF123164EBB00F25DCC /* RateCell.xib */, 181 | ); 182 | path = Views; 183 | sourceTree = ""; 184 | }; 185 | A6606DFA23167CA800F25DCC /* CoreData */ = { 186 | isa = PBXGroup; 187 | children = ( 188 | A6F19C2F231567CD00DB8B17 /* Rates.xcdatamodeld */, 189 | A6E189D82316E1C4009F9DCE /* Rate+CoreDataClass.swift */, 190 | A6E189D92316E1C4009F9DCE /* Rate+CoreDataProperties.swift */, 191 | A6606DD023162AF700F25DCC /* Currency+CoreDataClass.swift */, 192 | A6606DD123162AF700F25DCC /* Currency+CoreDataProperties.swift */, 193 | ); 194 | path = CoreData; 195 | sourceTree = ""; 196 | }; 197 | A66194E62318F0BC00535708 /* TestHelpers */ = { 198 | isa = PBXGroup; 199 | children = ( 200 | A64AA8712317FA81008FB8C0 /* RequestMocker.swift */, 201 | A66194E42318F08F00535708 /* InMemoryAppDefaults.swift */, 202 | ); 203 | path = TestHelpers; 204 | sourceTree = ""; 205 | }; 206 | A6E189CD2315A554009F9DCE /* Extensions */ = { 207 | isa = PBXGroup; 208 | children = ( 209 | A6606DDD231632AC00F25DCC /* UITableView_Extensions.swift */, 210 | A6606DE12316331F00F25DCC /* UITableViewCell_Extensions.swift */, 211 | A6B70653231BA9B3009A4596 /* NSManagedObject_Extensions.swift */, 212 | ); 213 | path = Extensions; 214 | sourceTree = ""; 215 | }; 216 | A6F19C1C231567CD00DB8B17 = { 217 | isa = PBXGroup; 218 | children = ( 219 | A6F19C27231567CD00DB8B17 /* Rates */, 220 | A6F19C3F231567CF00DB8B17 /* RatesTests */, 221 | A6F19C4A231567CF00DB8B17 /* RatesUITests */, 222 | A6F19C26231567CD00DB8B17 /* Products */, 223 | ); 224 | sourceTree = ""; 225 | }; 226 | A6F19C26231567CD00DB8B17 /* Products */ = { 227 | isa = PBXGroup; 228 | children = ( 229 | A6F19C25231567CD00DB8B17 /* Rates.app */, 230 | A6F19C3C231567CF00DB8B17 /* RatesTests.xctest */, 231 | A6F19C47231567CF00DB8B17 /* RatesUITests.xctest */, 232 | ); 233 | name = Products; 234 | sourceTree = ""; 235 | }; 236 | A6F19C27231567CD00DB8B17 /* Rates */ = { 237 | isa = PBXGroup; 238 | children = ( 239 | A6F19C28231567CD00DB8B17 /* AppDelegate.swift */, 240 | A6E189CD2315A554009F9DCE /* Extensions */, 241 | A6F19C5C23156AE000DB8B17 /* Models */, 242 | A6606DBA23161A9200F25DCC /* Protocols */, 243 | A6F19C5923156A8600DB8B17 /* Services */, 244 | A6606DD623162C3800F25DCC /* ViewControllers */, 245 | A6606DE32316332D00F25DCC /* Views */, 246 | A6606DC32316277C00F25DCC /* ViewModels */, 247 | A6F19C32231567CF00DB8B17 /* Assets.xcassets */, 248 | A6F19C34231567CF00DB8B17 /* LaunchScreen.storyboard */, 249 | A6F19C37231567CF00DB8B17 /* Info.plist */, 250 | ); 251 | path = Rates; 252 | sourceTree = ""; 253 | }; 254 | A6F19C3F231567CF00DB8B17 /* RatesTests */ = { 255 | isa = PBXGroup; 256 | children = ( 257 | A64AA87E23180534008FB8C0 /* mock-resources */, 258 | A66194E62318F0BC00535708 /* TestHelpers */, 259 | A6F19C42231567CF00DB8B17 /* Info.plist */, 260 | A6E189D02315B0F6009F9DCE /* APIServiceTests.swift */, 261 | A64AA880231837DE008FB8C0 /* RatesViewModelTests.swift */, 262 | ); 263 | path = RatesTests; 264 | sourceTree = ""; 265 | }; 266 | A6F19C4A231567CF00DB8B17 /* RatesUITests */ = { 267 | isa = PBXGroup; 268 | children = ( 269 | A6F19C4B231567CF00DB8B17 /* RatesUITests.swift */, 270 | A6F19C4D231567CF00DB8B17 /* Info.plist */, 271 | ); 272 | path = RatesUITests; 273 | sourceTree = ""; 274 | }; 275 | A6F19C5923156A8600DB8B17 /* Services */ = { 276 | isa = PBXGroup; 277 | children = ( 278 | A6F19C65231576C300DB8B17 /* APIService.swift */, 279 | A6606DE823163CBC00F25DCC /* RequestCaller.swift */, 280 | A6606DF42316777D00F25DCC /* NSFetchResultControllerDelegateWrapper.swift */, 281 | A66194E02318E83A00535708 /* AppDefaults.swift */, 282 | ); 283 | path = Services; 284 | sourceTree = ""; 285 | }; 286 | A6F19C5C23156AE000DB8B17 /* Models */ = { 287 | isa = PBXGroup; 288 | children = ( 289 | A6606DFA23167CA800F25DCC /* CoreData */, 290 | A6DCB3FD23158D3500FAF71F /* ErrorInfo.swift */, 291 | A6606DBF23161B1000F25DCC /* Currencies.swift */, 292 | A6606DC123161CE500F25DCC /* Quotes.swift */, 293 | A655220C23178BE200852430 /* RequestError.swift */, 294 | A655221023179F0E00852430 /* ConvertedRate.swift */, 295 | ); 296 | path = Models; 297 | sourceTree = ""; 298 | }; 299 | /* End PBXGroup section */ 300 | 301 | /* Begin PBXNativeTarget section */ 302 | A6F19C24231567CD00DB8B17 /* Rates */ = { 303 | isa = PBXNativeTarget; 304 | buildConfigurationList = A6F19C50231567CF00DB8B17 /* Build configuration list for PBXNativeTarget "Rates" */; 305 | buildPhases = ( 306 | A6F19C21231567CD00DB8B17 /* Sources */, 307 | A6F19C22231567CD00DB8B17 /* Frameworks */, 308 | A6F19C23231567CD00DB8B17 /* Resources */, 309 | ); 310 | buildRules = ( 311 | ); 312 | dependencies = ( 313 | ); 314 | name = Rates; 315 | productName = Rates; 316 | productReference = A6F19C25231567CD00DB8B17 /* Rates.app */; 317 | productType = "com.apple.product-type.application"; 318 | }; 319 | A6F19C3B231567CF00DB8B17 /* RatesTests */ = { 320 | isa = PBXNativeTarget; 321 | buildConfigurationList = A6F19C53231567CF00DB8B17 /* Build configuration list for PBXNativeTarget "RatesTests" */; 322 | buildPhases = ( 323 | A6F19C38231567CF00DB8B17 /* Sources */, 324 | A6F19C39231567CF00DB8B17 /* Frameworks */, 325 | A6F19C3A231567CF00DB8B17 /* Resources */, 326 | ); 327 | buildRules = ( 328 | ); 329 | dependencies = ( 330 | A6F19C3E231567CF00DB8B17 /* PBXTargetDependency */, 331 | ); 332 | name = RatesTests; 333 | productName = RatesTests; 334 | productReference = A6F19C3C231567CF00DB8B17 /* RatesTests.xctest */; 335 | productType = "com.apple.product-type.bundle.unit-test"; 336 | }; 337 | A6F19C46231567CF00DB8B17 /* RatesUITests */ = { 338 | isa = PBXNativeTarget; 339 | buildConfigurationList = A6F19C56231567CF00DB8B17 /* Build configuration list for PBXNativeTarget "RatesUITests" */; 340 | buildPhases = ( 341 | A6F19C43231567CF00DB8B17 /* Sources */, 342 | A6F19C44231567CF00DB8B17 /* Frameworks */, 343 | A6F19C45231567CF00DB8B17 /* Resources */, 344 | ); 345 | buildRules = ( 346 | ); 347 | dependencies = ( 348 | A6F19C49231567CF00DB8B17 /* PBXTargetDependency */, 349 | ); 350 | name = RatesUITests; 351 | productName = RatesUITests; 352 | productReference = A6F19C47231567CF00DB8B17 /* RatesUITests.xctest */; 353 | productType = "com.apple.product-type.bundle.ui-testing"; 354 | }; 355 | /* End PBXNativeTarget section */ 356 | 357 | /* Begin PBXProject section */ 358 | A6F19C1D231567CD00DB8B17 /* Project object */ = { 359 | isa = PBXProject; 360 | attributes = { 361 | LastSwiftUpdateCheck = 1020; 362 | LastUpgradeCheck = 1020; 363 | ORGANIZATIONNAME = "Michael Henry Pantaleon"; 364 | TargetAttributes = { 365 | A6F19C24231567CD00DB8B17 = { 366 | CreatedOnToolsVersion = 10.2; 367 | }; 368 | A6F19C3B231567CF00DB8B17 = { 369 | CreatedOnToolsVersion = 10.2; 370 | TestTargetID = A6F19C24231567CD00DB8B17; 371 | }; 372 | A6F19C46231567CF00DB8B17 = { 373 | CreatedOnToolsVersion = 10.2; 374 | TestTargetID = A6F19C24231567CD00DB8B17; 375 | }; 376 | }; 377 | }; 378 | buildConfigurationList = A6F19C20231567CD00DB8B17 /* Build configuration list for PBXProject "Rates" */; 379 | compatibilityVersion = "Xcode 9.3"; 380 | developmentRegion = en; 381 | hasScannedForEncodings = 0; 382 | knownRegions = ( 383 | en, 384 | Base, 385 | ); 386 | mainGroup = A6F19C1C231567CD00DB8B17; 387 | productRefGroup = A6F19C26231567CD00DB8B17 /* Products */; 388 | projectDirPath = ""; 389 | projectRoot = ""; 390 | targets = ( 391 | A6F19C24231567CD00DB8B17 /* Rates */, 392 | A6F19C3B231567CF00DB8B17 /* RatesTests */, 393 | A6F19C46231567CF00DB8B17 /* RatesUITests */, 394 | ); 395 | }; 396 | /* End PBXProject section */ 397 | 398 | /* Begin PBXResourcesBuildPhase section */ 399 | A6F19C23231567CD00DB8B17 /* Resources */ = { 400 | isa = PBXResourcesBuildPhase; 401 | buildActionMask = 2147483647; 402 | files = ( 403 | A6606DF323164EBB00F25DCC /* RateCell.xib in Resources */, 404 | A6606DE72316334300F25DCC /* CurrencyCell.xib in Resources */, 405 | A6F19C36231567CF00DB8B17 /* LaunchScreen.storyboard in Resources */, 406 | A6F19C33231567CF00DB8B17 /* Assets.xcassets in Resources */, 407 | A6F19C2E231567CD00DB8B17 /* Main.storyboard in Resources */, 408 | ); 409 | runOnlyForDeploymentPostprocessing = 0; 410 | }; 411 | A6F19C3A231567CF00DB8B17 /* Resources */ = { 412 | isa = PBXResourcesBuildPhase; 413 | buildActionMask = 2147483647; 414 | files = ( 415 | A64AA87F23180534008FB8C0 /* mock-resources in Resources */, 416 | ); 417 | runOnlyForDeploymentPostprocessing = 0; 418 | }; 419 | A6F19C45231567CF00DB8B17 /* Resources */ = { 420 | isa = PBXResourcesBuildPhase; 421 | buildActionMask = 2147483647; 422 | files = ( 423 | ); 424 | runOnlyForDeploymentPostprocessing = 0; 425 | }; 426 | /* End PBXResourcesBuildPhase section */ 427 | 428 | /* Begin PBXSourcesBuildPhase section */ 429 | A6F19C21231567CD00DB8B17 /* Sources */ = { 430 | isa = PBXSourcesBuildPhase; 431 | buildActionMask = 2147483647; 432 | files = ( 433 | A6B70654231BA9B3009A4596 /* NSManagedObject_Extensions.swift in Sources */, 434 | A6606DDC2316327B00F25DCC /* Reusable.swift in Sources */, 435 | A655221123179F0E00852430 /* ConvertedRate.swift in Sources */, 436 | A6606DF223164EBB00F25DCC /* RateCell.swift in Sources */, 437 | A6606DD82316313500F25DCC /* CurrenciesViewController.swift in Sources */, 438 | A6606DE0231632CE00F25DCC /* Bindable.swift in Sources */, 439 | A6606DC023161B1000F25DCC /* Currencies.swift in Sources */, 440 | A6606DDE231632AC00F25DCC /* UITableView_Extensions.swift in Sources */, 441 | A6606DF52316777D00F25DCC /* NSFetchResultControllerDelegateWrapper.swift in Sources */, 442 | A6606DE923163CBC00F25DCC /* RequestCaller.swift in Sources */, 443 | A6E189DA2316E1C4009F9DCE /* Rate+CoreDataClass.swift in Sources */, 444 | A6606DC223161CE500F25DCC /* Quotes.swift in Sources */, 445 | A655220F23178CB300852430 /* AlertShowable.swift in Sources */, 446 | A6DCB3FE23158D3500FAF71F /* ErrorInfo.swift in Sources */, 447 | A6606DEF23164E5F00F25DCC /* RatesViewController.swift in Sources */, 448 | A6606DD423162AF700F25DCC /* Currency+CoreDataClass.swift in Sources */, 449 | A6606DDA2316325800F25DCC /* NibLoadable.swift in Sources */, 450 | A6F19C31231567CD00DB8B17 /* Rates.xcdatamodeld in Sources */, 451 | A6606DC52316278800F25DCC /* CurrenciesViewModel.swift in Sources */, 452 | A6F19C66231576C300DB8B17 /* APIService.swift in Sources */, 453 | A6F19C29231567CD00DB8B17 /* AppDelegate.swift in Sources */, 454 | A66194E12318E83A00535708 /* AppDefaults.swift in Sources */, 455 | A6606DD523162AF700F25DCC /* Currency+CoreDataProperties.swift in Sources */, 456 | A6606DE62316334300F25DCC /* CurrencyCell.swift in Sources */, 457 | A655220D23178BE200852430 /* RequestError.swift in Sources */, 458 | A6606DE22316331F00F25DCC /* UITableViewCell_Extensions.swift in Sources */, 459 | A6606DED23164CCD00F25DCC /* RatesViewModel.swift in Sources */, 460 | A6E189DB2316E1C4009F9DCE /* Rate+CoreDataProperties.swift in Sources */, 461 | A66194E32318EA9900535708 /* AppDefaultsConvertible.swift in Sources */, 462 | ); 463 | runOnlyForDeploymentPostprocessing = 0; 464 | }; 465 | A6F19C38231567CF00DB8B17 /* Sources */ = { 466 | isa = PBXSourcesBuildPhase; 467 | buildActionMask = 2147483647; 468 | files = ( 469 | A6E189D12315B0F6009F9DCE /* APIServiceTests.swift in Sources */, 470 | A66194E52318F08F00535708 /* InMemoryAppDefaults.swift in Sources */, 471 | A64AA881231837DE008FB8C0 /* RatesViewModelTests.swift in Sources */, 472 | A64AA87D23180126008FB8C0 /* RequestMocker.swift in Sources */, 473 | ); 474 | runOnlyForDeploymentPostprocessing = 0; 475 | }; 476 | A6F19C43231567CF00DB8B17 /* Sources */ = { 477 | isa = PBXSourcesBuildPhase; 478 | buildActionMask = 2147483647; 479 | files = ( 480 | A6F19C4C231567CF00DB8B17 /* RatesUITests.swift in Sources */, 481 | ); 482 | runOnlyForDeploymentPostprocessing = 0; 483 | }; 484 | /* End PBXSourcesBuildPhase section */ 485 | 486 | /* Begin PBXTargetDependency section */ 487 | A6F19C3E231567CF00DB8B17 /* PBXTargetDependency */ = { 488 | isa = PBXTargetDependency; 489 | target = A6F19C24231567CD00DB8B17 /* Rates */; 490 | targetProxy = A6F19C3D231567CF00DB8B17 /* PBXContainerItemProxy */; 491 | }; 492 | A6F19C49231567CF00DB8B17 /* PBXTargetDependency */ = { 493 | isa = PBXTargetDependency; 494 | target = A6F19C24231567CD00DB8B17 /* Rates */; 495 | targetProxy = A6F19C48231567CF00DB8B17 /* PBXContainerItemProxy */; 496 | }; 497 | /* End PBXTargetDependency section */ 498 | 499 | /* Begin PBXVariantGroup section */ 500 | A6F19C2C231567CD00DB8B17 /* Main.storyboard */ = { 501 | isa = PBXVariantGroup; 502 | children = ( 503 | A6F19C2D231567CD00DB8B17 /* Base */, 504 | ); 505 | name = Main.storyboard; 506 | sourceTree = ""; 507 | }; 508 | A6F19C34231567CF00DB8B17 /* LaunchScreen.storyboard */ = { 509 | isa = PBXVariantGroup; 510 | children = ( 511 | A6F19C35231567CF00DB8B17 /* Base */, 512 | ); 513 | name = LaunchScreen.storyboard; 514 | sourceTree = ""; 515 | }; 516 | /* End PBXVariantGroup section */ 517 | 518 | /* Begin XCBuildConfiguration section */ 519 | A6F19C4E231567CF00DB8B17 /* Debug */ = { 520 | isa = XCBuildConfiguration; 521 | buildSettings = { 522 | ALWAYS_SEARCH_USER_PATHS = NO; 523 | CLANG_ANALYZER_NONNULL = YES; 524 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 525 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 526 | CLANG_CXX_LIBRARY = "libc++"; 527 | CLANG_ENABLE_MODULES = YES; 528 | CLANG_ENABLE_OBJC_ARC = YES; 529 | CLANG_ENABLE_OBJC_WEAK = YES; 530 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 531 | CLANG_WARN_BOOL_CONVERSION = YES; 532 | CLANG_WARN_COMMA = YES; 533 | CLANG_WARN_CONSTANT_CONVERSION = YES; 534 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 535 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 536 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 537 | CLANG_WARN_EMPTY_BODY = YES; 538 | CLANG_WARN_ENUM_CONVERSION = YES; 539 | CLANG_WARN_INFINITE_RECURSION = YES; 540 | CLANG_WARN_INT_CONVERSION = YES; 541 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 542 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 543 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 544 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 545 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 546 | CLANG_WARN_STRICT_PROTOTYPES = YES; 547 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 548 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 549 | CLANG_WARN_UNREACHABLE_CODE = YES; 550 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 551 | CODE_SIGN_IDENTITY = "iPhone Developer"; 552 | COPY_PHASE_STRIP = NO; 553 | DEBUG_INFORMATION_FORMAT = dwarf; 554 | ENABLE_STRICT_OBJC_MSGSEND = YES; 555 | ENABLE_TESTABILITY = YES; 556 | GCC_C_LANGUAGE_STANDARD = gnu11; 557 | GCC_DYNAMIC_NO_PIC = NO; 558 | GCC_NO_COMMON_BLOCKS = YES; 559 | GCC_OPTIMIZATION_LEVEL = 0; 560 | GCC_PREPROCESSOR_DEFINITIONS = ( 561 | "DEBUG=1", 562 | "$(inherited)", 563 | ); 564 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 565 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 566 | GCC_WARN_UNDECLARED_SELECTOR = YES; 567 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 568 | GCC_WARN_UNUSED_FUNCTION = YES; 569 | GCC_WARN_UNUSED_VARIABLE = YES; 570 | IPHONEOS_DEPLOYMENT_TARGET = 12.2; 571 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 572 | MTL_FAST_MATH = YES; 573 | ONLY_ACTIVE_ARCH = YES; 574 | SDKROOT = iphoneos; 575 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 576 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 577 | }; 578 | name = Debug; 579 | }; 580 | A6F19C4F231567CF00DB8B17 /* Release */ = { 581 | isa = XCBuildConfiguration; 582 | buildSettings = { 583 | ALWAYS_SEARCH_USER_PATHS = NO; 584 | CLANG_ANALYZER_NONNULL = YES; 585 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 586 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 587 | CLANG_CXX_LIBRARY = "libc++"; 588 | CLANG_ENABLE_MODULES = YES; 589 | CLANG_ENABLE_OBJC_ARC = YES; 590 | CLANG_ENABLE_OBJC_WEAK = YES; 591 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 592 | CLANG_WARN_BOOL_CONVERSION = YES; 593 | CLANG_WARN_COMMA = YES; 594 | CLANG_WARN_CONSTANT_CONVERSION = YES; 595 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 596 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 597 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 598 | CLANG_WARN_EMPTY_BODY = YES; 599 | CLANG_WARN_ENUM_CONVERSION = YES; 600 | CLANG_WARN_INFINITE_RECURSION = YES; 601 | CLANG_WARN_INT_CONVERSION = YES; 602 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 603 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 604 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 605 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 606 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 607 | CLANG_WARN_STRICT_PROTOTYPES = YES; 608 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 609 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 610 | CLANG_WARN_UNREACHABLE_CODE = YES; 611 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 612 | CODE_SIGN_IDENTITY = "iPhone Developer"; 613 | COPY_PHASE_STRIP = NO; 614 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 615 | ENABLE_NS_ASSERTIONS = NO; 616 | ENABLE_STRICT_OBJC_MSGSEND = YES; 617 | GCC_C_LANGUAGE_STANDARD = gnu11; 618 | GCC_NO_COMMON_BLOCKS = YES; 619 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 620 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 621 | GCC_WARN_UNDECLARED_SELECTOR = YES; 622 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 623 | GCC_WARN_UNUSED_FUNCTION = YES; 624 | GCC_WARN_UNUSED_VARIABLE = YES; 625 | IPHONEOS_DEPLOYMENT_TARGET = 12.2; 626 | MTL_ENABLE_DEBUG_INFO = NO; 627 | MTL_FAST_MATH = YES; 628 | SDKROOT = iphoneos; 629 | SWIFT_COMPILATION_MODE = wholemodule; 630 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 631 | VALIDATE_PRODUCT = YES; 632 | }; 633 | name = Release; 634 | }; 635 | A6F19C51231567CF00DB8B17 /* Debug */ = { 636 | isa = XCBuildConfiguration; 637 | buildSettings = { 638 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 639 | CODE_SIGN_STYLE = Automatic; 640 | INFOPLIST_FILE = Rates/Info.plist; 641 | IPHONEOS_DEPLOYMENT_TARGET = 11.0; 642 | LD_RUNPATH_SEARCH_PATHS = ( 643 | "$(inherited)", 644 | "@executable_path/Frameworks", 645 | ); 646 | PRODUCT_BUNDLE_IDENTIFIER = net.iamkel.Rates; 647 | PRODUCT_NAME = "$(TARGET_NAME)"; 648 | SWIFT_VERSION = 5.0; 649 | TARGETED_DEVICE_FAMILY = "1,2"; 650 | }; 651 | name = Debug; 652 | }; 653 | A6F19C52231567CF00DB8B17 /* Release */ = { 654 | isa = XCBuildConfiguration; 655 | buildSettings = { 656 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 657 | CODE_SIGN_STYLE = Automatic; 658 | INFOPLIST_FILE = Rates/Info.plist; 659 | IPHONEOS_DEPLOYMENT_TARGET = 11.0; 660 | LD_RUNPATH_SEARCH_PATHS = ( 661 | "$(inherited)", 662 | "@executable_path/Frameworks", 663 | ); 664 | PRODUCT_BUNDLE_IDENTIFIER = net.iamkel.Rates; 665 | PRODUCT_NAME = "$(TARGET_NAME)"; 666 | SWIFT_VERSION = 5.0; 667 | TARGETED_DEVICE_FAMILY = "1,2"; 668 | }; 669 | name = Release; 670 | }; 671 | A6F19C54231567CF00DB8B17 /* Debug */ = { 672 | isa = XCBuildConfiguration; 673 | buildSettings = { 674 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 675 | BUNDLE_LOADER = "$(TEST_HOST)"; 676 | CODE_SIGN_STYLE = Automatic; 677 | INFOPLIST_FILE = RatesTests/Info.plist; 678 | LD_RUNPATH_SEARCH_PATHS = ( 679 | "$(inherited)", 680 | "@executable_path/Frameworks", 681 | "@loader_path/Frameworks", 682 | ); 683 | PRODUCT_BUNDLE_IDENTIFIER = net.iamkel.RatesTests; 684 | PRODUCT_NAME = "$(TARGET_NAME)"; 685 | SWIFT_VERSION = 5.0; 686 | TARGETED_DEVICE_FAMILY = "1,2"; 687 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Rates.app/Rates"; 688 | }; 689 | name = Debug; 690 | }; 691 | A6F19C55231567CF00DB8B17 /* Release */ = { 692 | isa = XCBuildConfiguration; 693 | buildSettings = { 694 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 695 | BUNDLE_LOADER = "$(TEST_HOST)"; 696 | CODE_SIGN_STYLE = Automatic; 697 | INFOPLIST_FILE = RatesTests/Info.plist; 698 | LD_RUNPATH_SEARCH_PATHS = ( 699 | "$(inherited)", 700 | "@executable_path/Frameworks", 701 | "@loader_path/Frameworks", 702 | ); 703 | PRODUCT_BUNDLE_IDENTIFIER = net.iamkel.RatesTests; 704 | PRODUCT_NAME = "$(TARGET_NAME)"; 705 | SWIFT_VERSION = 5.0; 706 | TARGETED_DEVICE_FAMILY = "1,2"; 707 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Rates.app/Rates"; 708 | }; 709 | name = Release; 710 | }; 711 | A6F19C57231567CF00DB8B17 /* Debug */ = { 712 | isa = XCBuildConfiguration; 713 | buildSettings = { 714 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 715 | CODE_SIGN_STYLE = Automatic; 716 | INFOPLIST_FILE = RatesUITests/Info.plist; 717 | LD_RUNPATH_SEARCH_PATHS = ( 718 | "$(inherited)", 719 | "@executable_path/Frameworks", 720 | "@loader_path/Frameworks", 721 | ); 722 | PRODUCT_BUNDLE_IDENTIFIER = net.iamkel.RatesUITests; 723 | PRODUCT_NAME = "$(TARGET_NAME)"; 724 | SWIFT_VERSION = 5.0; 725 | TARGETED_DEVICE_FAMILY = "1,2"; 726 | TEST_TARGET_NAME = Rates; 727 | }; 728 | name = Debug; 729 | }; 730 | A6F19C58231567CF00DB8B17 /* Release */ = { 731 | isa = XCBuildConfiguration; 732 | buildSettings = { 733 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 734 | CODE_SIGN_STYLE = Automatic; 735 | INFOPLIST_FILE = RatesUITests/Info.plist; 736 | LD_RUNPATH_SEARCH_PATHS = ( 737 | "$(inherited)", 738 | "@executable_path/Frameworks", 739 | "@loader_path/Frameworks", 740 | ); 741 | PRODUCT_BUNDLE_IDENTIFIER = net.iamkel.RatesUITests; 742 | PRODUCT_NAME = "$(TARGET_NAME)"; 743 | SWIFT_VERSION = 5.0; 744 | TARGETED_DEVICE_FAMILY = "1,2"; 745 | TEST_TARGET_NAME = Rates; 746 | }; 747 | name = Release; 748 | }; 749 | /* End XCBuildConfiguration section */ 750 | 751 | /* Begin XCConfigurationList section */ 752 | A6F19C20231567CD00DB8B17 /* Build configuration list for PBXProject "Rates" */ = { 753 | isa = XCConfigurationList; 754 | buildConfigurations = ( 755 | A6F19C4E231567CF00DB8B17 /* Debug */, 756 | A6F19C4F231567CF00DB8B17 /* Release */, 757 | ); 758 | defaultConfigurationIsVisible = 0; 759 | defaultConfigurationName = Release; 760 | }; 761 | A6F19C50231567CF00DB8B17 /* Build configuration list for PBXNativeTarget "Rates" */ = { 762 | isa = XCConfigurationList; 763 | buildConfigurations = ( 764 | A6F19C51231567CF00DB8B17 /* Debug */, 765 | A6F19C52231567CF00DB8B17 /* Release */, 766 | ); 767 | defaultConfigurationIsVisible = 0; 768 | defaultConfigurationName = Release; 769 | }; 770 | A6F19C53231567CF00DB8B17 /* Build configuration list for PBXNativeTarget "RatesTests" */ = { 771 | isa = XCConfigurationList; 772 | buildConfigurations = ( 773 | A6F19C54231567CF00DB8B17 /* Debug */, 774 | A6F19C55231567CF00DB8B17 /* Release */, 775 | ); 776 | defaultConfigurationIsVisible = 0; 777 | defaultConfigurationName = Release; 778 | }; 779 | A6F19C56231567CF00DB8B17 /* Build configuration list for PBXNativeTarget "RatesUITests" */ = { 780 | isa = XCConfigurationList; 781 | buildConfigurations = ( 782 | A6F19C57231567CF00DB8B17 /* Debug */, 783 | A6F19C58231567CF00DB8B17 /* Release */, 784 | ); 785 | defaultConfigurationIsVisible = 0; 786 | defaultConfigurationName = Release; 787 | }; 788 | /* End XCConfigurationList section */ 789 | 790 | /* Begin XCVersionGroup section */ 791 | A6F19C2F231567CD00DB8B17 /* Rates.xcdatamodeld */ = { 792 | isa = XCVersionGroup; 793 | children = ( 794 | A6F19C30231567CD00DB8B17 /* Rates.xcdatamodel */, 795 | ); 796 | currentVersion = A6F19C30231567CD00DB8B17 /* Rates.xcdatamodel */; 797 | path = Rates.xcdatamodeld; 798 | sourceTree = ""; 799 | versionGroupType = wrapper.xcdatamodel; 800 | }; 801 | /* End XCVersionGroup section */ 802 | }; 803 | rootObject = A6F19C1D231567CD00DB8B17 /* Project object */; 804 | } 805 | --------------------------------------------------------------------------------