├── .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 | 
2 |
3 | [](https://travis-ci.org/michaelhenry/Rates) [](#)
4 |
5 |  
6 |
7 |     
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 | 
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 |
--------------------------------------------------------------------------------