From 7b19d6302c32494476202b114fb97d060051c351 Mon Sep 17 00:00:00 2001 From: Allan Lang Date: Mon, 12 Sep 2022 12:10:55 +0100 Subject: [PATCH 1/5] Removal of add site functionality --- Tree Tracker.xcodeproj/project.pbxproj | 4 - .../Screens/Settings/AddSiteController.swift | 88 ------------------- .../Screens/Settings/SitesController.swift | 13 +-- .../Airtable/AirtableSiteService.swift | 25 ------ .../ProtectEarthSiteService.swift | 24 ----- Tree Tracker/Services/SiteService.swift | 1 - 6 files changed, 2 insertions(+), 153 deletions(-) delete mode 100644 Tree Tracker/Screens/Settings/AddSiteController.swift diff --git a/Tree Tracker.xcodeproj/project.pbxproj b/Tree Tracker.xcodeproj/project.pbxproj index 3579d78..30c3fdb 100644 --- a/Tree Tracker.xcodeproj/project.pbxproj +++ b/Tree Tracker.xcodeproj/project.pbxproj @@ -142,7 +142,6 @@ 9DB29B562821C28400AAC73D /* SettingsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DB29B552821C28300AAC73D /* SettingsController.swift */; }; 9DB29B582821D50000AAC73D /* SimpleTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DB29B572821D50000AAC73D /* SimpleTableViewCell.swift */; }; 9DB29B5A282876B700AAC73D /* SitesController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DB29B59282876B700AAC73D /* SitesController.swift */; }; - 9DB29B5E282C0AEB00AAC73D /* AddSiteController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DB29B5D282C0AEB00AAC73D /* AddSiteController.swift */; }; 9DCC548C28073F0A00CF67AA /* Resolver in Frameworks */ = {isa = PBXBuildFile; productRef = 9DCC548B28073F0A00CF67AA /* Resolver */; }; 9DFB9CC1284E9BF500298526 /* AirtableSiteServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DFB9CC0284E9BF500298526 /* AirtableSiteServiceTests.swift */; }; 9DFB9CC52851345800298526 /* AirtableSessionFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DFB9CC42851345800298526 /* AirtableSessionFactory.swift */; }; @@ -297,7 +296,6 @@ 9DB29B552821C28300AAC73D /* SettingsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsController.swift; sourceTree = ""; }; 9DB29B572821D50000AAC73D /* SimpleTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleTableViewCell.swift; sourceTree = ""; }; 9DB29B59282876B700AAC73D /* SitesController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SitesController.swift; sourceTree = ""; }; - 9DB29B5D282C0AEB00AAC73D /* AddSiteController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddSiteController.swift; sourceTree = ""; }; 9DFB9CC0284E9BF500298526 /* AirtableSiteServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirtableSiteServiceTests.swift; sourceTree = ""; }; 9DFB9CC42851345800298526 /* AirtableSessionFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirtableSessionFactory.swift; sourceTree = ""; }; 9DFF45C328C69AAD00D45C73 /* CloudinaryUploadResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudinaryUploadResponse.swift; sourceTree = ""; }; @@ -663,7 +661,6 @@ 9DB29B552821C28300AAC73D /* SettingsController.swift */, 9DB29B59282876B700AAC73D /* SitesController.swift */, 9DFF6276282E63100008AEEF /* SupervisorsController.swift */, - 9DB29B5D282C0AEB00AAC73D /* AddSiteController.swift */, 9DFF6278282E64780008AEEF /* SpeciesController.swift */, ); path = Settings; @@ -835,7 +832,6 @@ 85B83A1525B87E780008E167 /* UploadViewModel.swift in Sources */, 85792A7625B0A35A00BFDA96 /* UIImage.swift in Sources */, 85792A7A25B0A36B00BFDA96 /* URL.swift in Sources */, - 9DB29B5E282C0AEB00AAC73D /* AddSiteController.swift in Sources */, 85B839DD25B74FE00008E167 /* KeyboardAccessory.swift in Sources */, 9D5D5E28284B630D00F3AD3E /* SpeciesService.swift in Sources */, 85B83A1825B881EC0008E167 /* LocalTree.swift in Sources */, diff --git a/Tree Tracker/Screens/Settings/AddSiteController.swift b/Tree Tracker/Screens/Settings/AddSiteController.swift deleted file mode 100644 index 322c803..0000000 --- a/Tree Tracker/Screens/Settings/AddSiteController.swift +++ /dev/null @@ -1,88 +0,0 @@ -import Foundation -import UIKit -import Resolver - -/* - Controller for sheet view used to supply and save a new site - */ -class AddSiteController: UIViewController, UITextFieldDelegate { - - private var siteService: SiteService - - init(siteService: SiteService) { - self.siteService = siteService - super.init(nibName: nil, bundle: nil) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func loadView() { - view = UIView() - view.backgroundColor = .systemBackground - - // text field - view.addSubview(stackView) - stackView.addArrangedSubview(textField) - textField.placeholder = "Enter site name" - - // save button - let buttonModel = ButtonModel(title: ButtonModel.Title.text("Save"), action: {self.doSave()}, isEnabled: true) - actionButton.set(model: buttonModel) - stackView.addArrangedSubview(actionButton) - - // layout - NSLayoutConstraint.activate([ - textField.widthAnchor.constraint(equalTo: view.widthAnchor, multiplier: 0.9), - - actionButton.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor, constant: -10.0), - actionButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), - actionButton.widthAnchor.constraint(equalTo: view.widthAnchor, multiplier: 0.5) - ]) - } - - // MARK: - UI controls - private let stackView: UIStackView = { - let stackView = UIStackView() - stackView.translatesAutoresizingMaskIntoConstraints = false - stackView.axis = .vertical - stackView.alignment = .center - stackView.spacing = 16.0 - stackView.setContentCompressionResistancePriority(.required, for: .horizontal) - stackView.setContentCompressionResistancePriority(.required, for: .vertical) - - return stackView - }() - - private let actionButton: TappableButton = { - let button = RoundedTappableButton(type: .system) - button.translatesAutoresizingMaskIntoConstraints = false - button.titleLabel?.font = .systemFont(ofSize: 18.0) - return button - }() - - private let textField: TextField = { - let textField = TextField() - textField.textColor = .label - textField.borderStyle = .roundedRect - textField.translatesAutoresizingMaskIntoConstraints = false - textField.heightAnchor.constraint(equalToConstant: 48.0).isActive = true - return textField - }() - - // MARK: - Delegate - private func doSave() -> Void? { - if(textField.hasText) { - // set action button to spinner / working - actionButton.set(title: .loading) - - siteService.addSite(name: textField.text!, completion: { [weak self] result in - self?.dismiss(animated: true) - }) - } - // just ignore the tap if there is no text in the text box - tap outside sheet to dismiss - return () - } - -} diff --git a/Tree Tracker/Screens/Settings/SitesController.swift b/Tree Tracker/Screens/Settings/SitesController.swift index 413b97c..f26b4cf 100644 --- a/Tree Tracker/Screens/Settings/SitesController.swift +++ b/Tree Tracker/Screens/Settings/SitesController.swift @@ -21,8 +21,7 @@ class SitesController: UITableViewController { self.tableView.register(SimpleTableViewCell.self, forCellReuseIdentifier: "basicStyle") // nav bar controls - navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(addTapped)) - navigationItem.rightBarButtonItems?.append(UIBarButtonItem(barButtonSystemItem: .refresh, target: self, action: #selector(refreshTapped))) + navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .refresh, target: self, action: #selector(refreshTapped)) // Here we are creating a Combine subscription to a @Published attribute of the SiteService which is handling data access. // The closure will be invoked on any change to the data property. @@ -33,15 +32,7 @@ class SitesController: UITableViewController { } } - // MARK: - navigation item delegates - @objc func addTapped() { - let addSiteController = AddSiteController(siteService: self.siteService) - if let sheet = addSiteController.sheetPresentationController { - sheet.detents = [ .medium() ] - } - present(addSiteController, animated: true) - } - + // MARK: - navigation item delegates @objc func refreshTapped() { siteService.sync() {_ in } } diff --git a/Tree Tracker/Services/Airtable/AirtableSiteService.swift b/Tree Tracker/Services/Airtable/AirtableSiteService.swift index b061467..95ffcb9 100644 --- a/Tree Tracker/Services/Airtable/AirtableSiteService.swift +++ b/Tree Tracker/Services/Airtable/AirtableSiteService.swift @@ -55,31 +55,6 @@ class AirtableSiteService: SiteService { } } - // Add a site to remote and trigger a sync to update local cache - func addSite(name: String, completion: @escaping (Result) -> Void) { - // build struct to represent target JSON body - let parameters: [String: [String: String]] = [ - "fields": ["Name": name] - ] - - let request = getSession().request(sessionFactory.getSitesUrl(), - method: .post, - parameters: parameters, - encoder: JSONParameterEncoder.default) - - request.validate().responseDecodable(of: AirtableSite.self, decoder: JSONDecoder._iso8601ms) { response in - switch response.result { - case .success: - self.sync { result in - completion(result) - } - case .failure: - completion(.failure(ProtectEarthError.remoteError(errorCode: response.error!.responseCode!, - errorMessage: (response.error!.errorDescription!)))) - } - } - } - private func getSession() -> Session { sessionFactory.get() } diff --git a/Tree Tracker/Services/ProtectEarth/ProtectEarthSiteService.swift b/Tree Tracker/Services/ProtectEarth/ProtectEarthSiteService.swift index 9a09121..4d227cf 100644 --- a/Tree Tracker/Services/ProtectEarth/ProtectEarthSiteService.swift +++ b/Tree Tracker/Services/ProtectEarth/ProtectEarthSiteService.swift @@ -54,30 +54,6 @@ class ProtectEarthSiteService: SiteService { } } - func addSite(name: String, completion: @escaping (Result) -> Void) { - // build struct to represent target JSON body - let parameters: [String: String] = [ - "name": name - ] - - let request = getSession().request(sessionFactory.getSitesUrl(), - method: .put, - parameters: parameters, - encoder: JSONParameterEncoder.default) - - request.validate().responseDecodable(of: ProtectEarthSite.self, decoder: JSONDecoder._iso8601ms) { response in - switch response.result { - case .success: - self.sync { result in - completion(result) - } - case .failure: - completion(.failure(ProtectEarthError.remoteError(errorCode: response.error!.responseCode!, - errorMessage: (response.error!.errorDescription!)))) - } - } - } - private func getSession() -> Session { sessionFactory.get() } diff --git a/Tree Tracker/Services/SiteService.swift b/Tree Tracker/Services/SiteService.swift index 71dbeb5..5c2a16c 100644 --- a/Tree Tracker/Services/SiteService.swift +++ b/Tree Tracker/Services/SiteService.swift @@ -5,6 +5,5 @@ protocol SiteService { // See https://swiftsenpai.com/swift/define-protocol-with-published-property-wrapper/ var sitesPublisher: Published<[Site]>.Publisher { get } func fetchAll(completion: @escaping (Result<[Site], ProtectEarthError>) -> Void) - func addSite(name: String, completion: @escaping (Result) -> Void) func sync(completion: @escaping (Result) -> Void) } From 8557acebff274e33b96806a97b7a670324166685 Mon Sep 17 00:00:00 2001 From: Allan Lang Date: Mon, 12 Sep 2022 12:38:27 +0100 Subject: [PATCH 2/5] Airtable code cull --- Tree Tracker.xcodeproj/project.pbxproj | 76 ------- Tree Tracker/AppDelegate+Injection.swift | 70 ++----- Tree Tracker/Constants.swift | 8 - Tree Tracker/Info.plist | 2 +- Tree Tracker/Models/Api/AirtableImage.swift | 32 --- .../Models/Api/AirtableImageThumbnail.swift | 29 --- Tree Tracker/Models/Api/AirtableSite.swift | 29 --- Tree Tracker/Models/Api/AirtableSpecies.swift | 29 --- .../Models/Api/AirtableSupervisor.swift | 29 --- Tree Tracker/Models/Api/AirtableTree.swift | 132 ------------- Tree Tracker/Models/Api/Paginated.swift | 6 - Tree Tracker/Models/Database/LocalTree.swift | 8 - Tree Tracker/Models/Database/RemoteTree.swift | 35 ---- .../Screens/Upload/UploadViewModel.swift | 1 - .../Airtable/AirtableSessionFactory.swift | 72 ------- .../Airtable/AirtableSiteService.swift | 62 ------ .../Airtable/AirtableSpeciesService.swift | 69 ------- .../Airtable/AirtableSupervisorService.swift | 67 ------- Tree Tracker/Services/AlamofireApi.swift | 186 ------------------ Tree Tracker/Services/Api.swift | 9 - Tree Tracker/Services/Database.swift | 29 --- Tree Tracker/Services/MockApi.swift | 82 -------- Tree Tracker/Utilities/URLImageLoader.swift | 83 -------- Unit Tests/AirtableSiteServiceTests.swift | 170 ---------------- Unit Tests/Info.plist | 2 +- Unit Tests/ProtectEarthSiteServiceTests.swift | 2 - .../ProtectEarthSpeciesServiceTests.swift | 2 - .../ProtectEarthSupervisorServiceTests.swift | 2 - 28 files changed, 17 insertions(+), 1306 deletions(-) delete mode 100644 Tree Tracker/Models/Api/AirtableImage.swift delete mode 100644 Tree Tracker/Models/Api/AirtableImageThumbnail.swift delete mode 100644 Tree Tracker/Models/Api/AirtableSite.swift delete mode 100644 Tree Tracker/Models/Api/AirtableSpecies.swift delete mode 100644 Tree Tracker/Models/Api/AirtableSupervisor.swift delete mode 100644 Tree Tracker/Models/Api/AirtableTree.swift delete mode 100644 Tree Tracker/Models/Api/Paginated.swift delete mode 100644 Tree Tracker/Models/Database/RemoteTree.swift delete mode 100644 Tree Tracker/Services/Airtable/AirtableSessionFactory.swift delete mode 100644 Tree Tracker/Services/Airtable/AirtableSiteService.swift delete mode 100644 Tree Tracker/Services/Airtable/AirtableSpeciesService.swift delete mode 100644 Tree Tracker/Services/Airtable/AirtableSupervisorService.swift delete mode 100644 Tree Tracker/Services/AlamofireApi.swift delete mode 100644 Tree Tracker/Services/Api.swift delete mode 100644 Tree Tracker/Services/MockApi.swift delete mode 100644 Tree Tracker/Utilities/URLImageLoader.swift delete mode 100644 Unit Tests/AirtableSiteServiceTests.swift diff --git a/Tree Tracker.xcodeproj/project.pbxproj b/Tree Tracker.xcodeproj/project.pbxproj index 30c3fdb..ff6422b 100644 --- a/Tree Tracker.xcodeproj/project.pbxproj +++ b/Tree Tracker.xcodeproj/project.pbxproj @@ -23,11 +23,8 @@ 853ABD622596144A00144B0D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 853ABD602596144A00144B0D /* LaunchScreen.storyboard */; }; 853ABD7325961B4800144B0D /* IDETemplateMacros.plist in Resources */ = {isa = PBXBuildFile; fileRef = 853ABD7225961B4800144B0D /* IDETemplateMacros.plist */; }; 853ABD7A25961C6500144B0D /* License.md in Resources */ = {isa = PBXBuildFile; fileRef = 853ABD7925961C6500144B0D /* License.md */; }; - 853ABD8025961EEA00144B0D /* Api.swift in Sources */ = {isa = PBXBuildFile; fileRef = 853ABD7F25961EEA00144B0D /* Api.swift */; }; 853ABD8925961F9700144B0D /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 853ABD8825961F9700144B0D /* Constants.swift */; }; 8563C263260BBC7A00752793 /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8563C262260BBC7A00752793 /* Secrets.swift */; }; - 857123C8263997FD008EE027 /* AlamofireApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 857123C7263997FD008EE027 /* AlamofireApi.swift */; }; - 857123CC26399B49008EE027 /* MockApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 857123CB26399B49008EE027 /* MockApi.swift */; }; 857123D02639C3AF008EE027 /* ClosureCancellable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 857123CF2639C3AF008EE027 /* ClosureCancellable.swift */; }; 85763A9425E29CE300CB4ED3 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85763A9325E29CE300CB4ED3 /* Logger.swift */; }; 85763A9D25E2B0AB00CB4ED3 /* RotatingUIImagePickerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85763A9C25E2B0AB00CB4ED3 /* RotatingUIImagePickerController.swift */; }; @@ -42,11 +39,6 @@ 857BADA825B1FA93005D7D35 /* TreeDetailsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 857BADA725B1FA93005D7D35 /* TreeDetailsViewController.swift */; }; 857BADAC25B1FAAA005D7D35 /* UploadListFlowViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 857BADAB25B1FAAA005D7D35 /* UploadListFlowViewController.swift */; }; 858A0F2725D8156100E12C2B /* TreeDetailsFlowViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 858A0F2625D8156100E12C2B /* TreeDetailsFlowViewController.swift */; }; - 859F62C025C1C436005E61F7 /* AirtableImageThumbnail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 859F62BF25C1C436005E61F7 /* AirtableImageThumbnail.swift */; }; - 859F62C325C1C44C005E61F7 /* AirtableImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 859F62C225C1C44C005E61F7 /* AirtableImage.swift */; }; - 859F62C625C1C464005E61F7 /* AirtableSpecies.swift in Sources */ = {isa = PBXBuildFile; fileRef = 859F62C525C1C464005E61F7 /* AirtableSpecies.swift */; }; - 859F62CD25C1C61E005E61F7 /* AirtableSite.swift in Sources */ = {isa = PBXBuildFile; fileRef = 859F62CC25C1C61E005E61F7 /* AirtableSite.swift */; }; - 859F62D125C1C62D005E61F7 /* AirtableSupervisor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 859F62D025C1C62D005E61F7 /* AirtableSupervisor.swift */; }; 859F62D625C22140005E61F7 /* Species.swift in Sources */ = {isa = PBXBuildFile; fileRef = 859F62D525C22140005E61F7 /* Species.swift */; }; 859F62D925C2215D005E61F7 /* Site.swift in Sources */ = {isa = PBXBuildFile; fileRef = 859F62D825C2215D005E61F7 /* Site.swift */; }; 859F62DC25C2218B005E61F7 /* Supervisor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 859F62DB25C2218B005E61F7 /* Supervisor.swift */; }; @@ -55,11 +47,9 @@ 859F62E925C48FA2005E61F7 /* DelayedPublished.swift in Sources */ = {isa = PBXBuildFile; fileRef = 859F62E825C48FA2005E61F7 /* DelayedPublished.swift */; }; 859F62EF25C48FD8005E61F7 /* AlertModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 859F62EE25C48FD8005E61F7 /* AlertModel.swift */; }; 859F62F225C5A7B8005E61F7 /* Cancellable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 859F62F125C5A7B8005E61F7 /* Cancellable.swift */; }; - 859F62F525C5A7DC005E61F7 /* Paginated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 859F62F425C5A7DC005E61F7 /* Paginated.swift */; }; 859F62FC25C70F29005E61F7 /* CollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 859F62FB25C70F29005E61F7 /* CollectionViewController.swift */; }; 85A0EF7F25A226D9003CE744 /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = 85A0EF7E25A226D9003CE744 /* GRDB */; }; 85A0EF8325A2271C003CE744 /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = 85A0EF8225A2271C003CE744 /* Alamofire */; }; - 85A0EF8925A22FEE003CE744 /* AirtableTree.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85A0EF8825A22FEE003CE744 /* AirtableTree.swift */; }; 85B839A925B35B540008E167 /* TableViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85B839A825B35B540008E167 /* TableViewDataSource.swift */; }; 85B839AD25B47BD30008E167 /* Reusable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85B839AC25B47BD30008E167 /* Reusable.swift */; }; 85B839B125B47C0A0008E167 /* Action.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85B839B025B47C0A0008E167 /* Action.swift */; }; @@ -82,7 +72,6 @@ 85B839F325B866590008E167 /* RoundedTappableButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85B839F225B866590008E167 /* RoundedTappableButton.swift */; }; 85B839F825B86F0A0008E167 /* PHAssetManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85B839F725B86F0A0008E167 /* PHAssetManager.swift */; }; 85B839FB25B86F1A0008E167 /* ImageLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85B839FA25B86F1A0008E167 /* ImageLoader.swift */; }; - 85B839FE25B86F320008E167 /* URLImageLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85B839FD25B86F320008E167 /* URLImageLoader.swift */; }; 85B83A0225B8735F0008E167 /* AnyImageLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85B83A0125B8735F0008E167 /* AnyImageLoader.swift */; }; 85B83A0D25B87C0D0008E167 /* BSImagePicker in Frameworks */ = {isa = PBXBuildFile; productRef = 85B83A0C25B87C0D0008E167 /* BSImagePicker */; }; 85B83A1525B87E780008E167 /* UploadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85B83A1425B87E780008E167 /* UploadViewModel.swift */; }; @@ -113,13 +102,10 @@ 9D47D982286F29E100F7B92F /* ProtectEarthCodableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D47D981286F29E100F7B92F /* ProtectEarthCodableTests.swift */; }; 9D562D6528B81BDE00B66716 /* ProtectEarthUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D562D6428B81BDE00B66716 /* ProtectEarthUpload.swift */; }; 9D562D6728B81D5400B66716 /* ProtectEarthIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D562D6628B81D5400B66716 /* ProtectEarthIdentifier.swift */; }; - 9D562D6B28C2BBDC00B66716 /* RemoteTree.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D562D6A28C2BBDC00B66716 /* RemoteTree.swift */; }; 9D562D7228C4129000B66716 /* CloudinarySessionFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D562D7128C4129000B66716 /* CloudinarySessionFactory.swift */; }; 9D5CDBD727BBC080007D4F0A /* ExportOptions.plist in Resources */ = {isa = PBXBuildFile; fileRef = 9D5CDBD627BBC080007D4F0A /* ExportOptions.plist */; }; 9D5D5E28284B630D00F3AD3E /* SpeciesService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D5D5E27284B630D00F3AD3E /* SpeciesService.swift */; }; - 9D5D5E2A284B635900F3AD3E /* AirtableSpeciesService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D5D5E29284B635900F3AD3E /* AirtableSpeciesService.swift */; }; 9D5D5E2C284B66BB00F3AD3E /* SupervisorService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D5D5E2B284B66BB00F3AD3E /* SupervisorService.swift */; }; - 9D5D5E2E284B670400F3AD3E /* AirtableSupervisorService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D5D5E2D284B670400F3AD3E /* AirtableSupervisorService.swift */; }; 9D5F0618286F346800C8D4A6 /* ProtectEarthSupervisorService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D5F0617286F346800C8D4A6 /* ProtectEarthSupervisorService.swift */; }; 9D5F061B286F348F00C8D4A6 /* ProtectEarthSessionFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D5F061A286F348F00C8D4A6 /* ProtectEarthSessionFactory.swift */; }; 9D5F061E286F35C700C8D4A6 /* BearerTokenAuthenticationAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D5F061D286F35C700C8D4A6 /* BearerTokenAuthenticationAdapter.swift */; }; @@ -131,7 +117,6 @@ 9D5F06332878ADF000C8D4A6 /* DataResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D5F06322878ADF000C8D4A6 /* DataResponse.swift */; }; 9D79A5A7283AE03100F0F96C /* SiteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D79A5A6283AE03100F0F96C /* SiteService.swift */; }; 9D79A5AA283AE27500F0F96C /* ProtectEarthError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D79A5A9283AE27500F0F96C /* ProtectEarthError.swift */; }; - 9D79A5AC283AE32C00F0F96C /* AirtableSiteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D79A5AB283AE32C00F0F96C /* AirtableSiteService.swift */; }; 9DA1FC34283452CC00AEC584 /* AppDelegate+Injection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DA1FC33283452CC00AEC584 /* AppDelegate+Injection.swift */; }; 9DA84707287DB9D400B0BB3E /* ProtectEarthSpecies.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DA84706287DB9D400B0BB3E /* ProtectEarthSpecies.swift */; }; 9DA84709287DBA3F00B0BB3E /* ProtectEarthSpeciesServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DA84708287DBA3F00B0BB3E /* ProtectEarthSpeciesServiceTests.swift */; }; @@ -143,8 +128,6 @@ 9DB29B582821D50000AAC73D /* SimpleTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DB29B572821D50000AAC73D /* SimpleTableViewCell.swift */; }; 9DB29B5A282876B700AAC73D /* SitesController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DB29B59282876B700AAC73D /* SitesController.swift */; }; 9DCC548C28073F0A00CF67AA /* Resolver in Frameworks */ = {isa = PBXBuildFile; productRef = 9DCC548B28073F0A00CF67AA /* Resolver */; }; - 9DFB9CC1284E9BF500298526 /* AirtableSiteServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DFB9CC0284E9BF500298526 /* AirtableSiteServiceTests.swift */; }; - 9DFB9CC52851345800298526 /* AirtableSessionFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DFB9CC42851345800298526 /* AirtableSessionFactory.swift */; }; 9DFF45C428C69AAD00D45C73 /* CloudinaryUploadResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DFF45C328C69AAD00D45C73 /* CloudinaryUploadResponse.swift */; }; 9DFF6277282E63100008AEEF /* SupervisorsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DFF6276282E63100008AEEF /* SupervisorsController.swift */; }; 9DFF6279282E64780008AEEF /* SpeciesController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DFF6278282E64780008AEEF /* SpeciesController.swift */; }; @@ -182,11 +165,8 @@ 853ABD7225961B4800144B0D /* IDETemplateMacros.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; name = IDETemplateMacros.plist; path = "Tree Tracker.xcodeproj/xcshareddata/IDETemplateMacros.plist"; sourceTree = SOURCE_ROOT; }; 853ABD7925961C6500144B0D /* License.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = License.md; sourceTree = ""; }; 853ABD7C25961C6D00144B0D /* Readme.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = Readme.md; sourceTree = ""; }; - 853ABD7F25961EEA00144B0D /* Api.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Api.swift; sourceTree = ""; }; 853ABD8825961F9700144B0D /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; 8563C262260BBC7A00752793 /* Secrets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Secrets.swift; sourceTree = ""; }; - 857123C7263997FD008EE027 /* AlamofireApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlamofireApi.swift; sourceTree = ""; }; - 857123CB26399B49008EE027 /* MockApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockApi.swift; sourceTree = ""; }; 857123CF2639C3AF008EE027 /* ClosureCancellable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClosureCancellable.swift; sourceTree = ""; }; 85763A9325E29CE300CB4ED3 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; 85763A9C25E2B0AB00CB4ED3 /* RotatingUIImagePickerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RotatingUIImagePickerController.swift; sourceTree = ""; }; @@ -201,11 +181,6 @@ 857BADA725B1FA93005D7D35 /* TreeDetailsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TreeDetailsViewController.swift; sourceTree = ""; }; 857BADAB25B1FAAA005D7D35 /* UploadListFlowViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadListFlowViewController.swift; sourceTree = ""; }; 858A0F2625D8156100E12C2B /* TreeDetailsFlowViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TreeDetailsFlowViewController.swift; sourceTree = ""; }; - 859F62BF25C1C436005E61F7 /* AirtableImageThumbnail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirtableImageThumbnail.swift; sourceTree = ""; }; - 859F62C225C1C44C005E61F7 /* AirtableImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirtableImage.swift; sourceTree = ""; }; - 859F62C525C1C464005E61F7 /* AirtableSpecies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirtableSpecies.swift; sourceTree = ""; }; - 859F62CC25C1C61E005E61F7 /* AirtableSite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirtableSite.swift; sourceTree = ""; }; - 859F62D025C1C62D005E61F7 /* AirtableSupervisor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirtableSupervisor.swift; sourceTree = ""; }; 859F62D525C22140005E61F7 /* Species.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Species.swift; sourceTree = ""; }; 859F62D825C2215D005E61F7 /* Site.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Site.swift; sourceTree = ""; }; 859F62DB25C2218B005E61F7 /* Supervisor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Supervisor.swift; sourceTree = ""; }; @@ -214,10 +189,8 @@ 859F62E825C48FA2005E61F7 /* DelayedPublished.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DelayedPublished.swift; sourceTree = ""; }; 859F62EE25C48FD8005E61F7 /* AlertModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertModel.swift; sourceTree = ""; }; 859F62F125C5A7B8005E61F7 /* Cancellable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cancellable.swift; sourceTree = ""; }; - 859F62F425C5A7DC005E61F7 /* Paginated.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Paginated.swift; sourceTree = ""; }; 859F62FB25C70F29005E61F7 /* CollectionViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CollectionViewController.swift; sourceTree = ""; }; 859F630125C7176E005E61F7 /* Changelog.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = Changelog.md; sourceTree = ""; }; - 85A0EF8825A22FEE003CE744 /* AirtableTree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirtableTree.swift; sourceTree = ""; }; 85B839A825B35B540008E167 /* TableViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewDataSource.swift; sourceTree = ""; }; 85B839AC25B47BD30008E167 /* Reusable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Reusable.swift; sourceTree = ""; }; 85B839B025B47C0A0008E167 /* Action.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Action.swift; sourceTree = ""; }; @@ -240,7 +213,6 @@ 85B839F225B866590008E167 /* RoundedTappableButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundedTappableButton.swift; sourceTree = ""; }; 85B839F725B86F0A0008E167 /* PHAssetManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PHAssetManager.swift; sourceTree = ""; }; 85B839FA25B86F1A0008E167 /* ImageLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageLoader.swift; sourceTree = ""; }; - 85B839FD25B86F320008E167 /* URLImageLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLImageLoader.swift; sourceTree = ""; }; 85B83A0125B8735F0008E167 /* AnyImageLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyImageLoader.swift; sourceTree = ""; }; 85B83A1425B87E780008E167 /* UploadViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadViewModel.swift; sourceTree = ""; }; 85B83A1725B881EC0008E167 /* LocalTree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalTree.swift; sourceTree = ""; }; @@ -267,13 +239,10 @@ 9D47D981286F29E100F7B92F /* ProtectEarthCodableTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProtectEarthCodableTests.swift; sourceTree = ""; }; 9D562D6428B81BDE00B66716 /* ProtectEarthUpload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProtectEarthUpload.swift; sourceTree = ""; }; 9D562D6628B81D5400B66716 /* ProtectEarthIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProtectEarthIdentifier.swift; sourceTree = ""; }; - 9D562D6A28C2BBDC00B66716 /* RemoteTree.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteTree.swift; sourceTree = ""; }; 9D562D7128C4129000B66716 /* CloudinarySessionFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudinarySessionFactory.swift; sourceTree = ""; }; 9D5CDBD627BBC080007D4F0A /* ExportOptions.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = ExportOptions.plist; sourceTree = ""; }; 9D5D5E27284B630D00F3AD3E /* SpeciesService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeciesService.swift; sourceTree = ""; }; - 9D5D5E29284B635900F3AD3E /* AirtableSpeciesService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirtableSpeciesService.swift; sourceTree = ""; }; 9D5D5E2B284B66BB00F3AD3E /* SupervisorService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupervisorService.swift; sourceTree = ""; }; - 9D5D5E2D284B670400F3AD3E /* AirtableSupervisorService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirtableSupervisorService.swift; sourceTree = ""; }; 9D5F0617286F346800C8D4A6 /* ProtectEarthSupervisorService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProtectEarthSupervisorService.swift; sourceTree = ""; }; 9D5F061A286F348F00C8D4A6 /* ProtectEarthSessionFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProtectEarthSessionFactory.swift; sourceTree = ""; }; 9D5F061D286F35C700C8D4A6 /* BearerTokenAuthenticationAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BearerTokenAuthenticationAdapter.swift; sourceTree = ""; }; @@ -285,7 +254,6 @@ 9D5F06322878ADF000C8D4A6 /* DataResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DataResponse.swift; sourceTree = ""; }; 9D79A5A6283AE03100F0F96C /* SiteService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteService.swift; sourceTree = ""; }; 9D79A5A9283AE27500F0F96C /* ProtectEarthError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProtectEarthError.swift; sourceTree = ""; }; - 9D79A5AB283AE32C00F0F96C /* AirtableSiteService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirtableSiteService.swift; sourceTree = ""; }; 9DA1FC33283452CC00AEC584 /* AppDelegate+Injection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+Injection.swift"; sourceTree = ""; }; 9DA84706287DB9D400B0BB3E /* ProtectEarthSpecies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProtectEarthSpecies.swift; sourceTree = ""; }; 9DA84708287DBA3F00B0BB3E /* ProtectEarthSpeciesServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProtectEarthSpeciesServiceTests.swift; sourceTree = ""; }; @@ -296,8 +264,6 @@ 9DB29B552821C28300AAC73D /* SettingsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsController.swift; sourceTree = ""; }; 9DB29B572821D50000AAC73D /* SimpleTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleTableViewCell.swift; sourceTree = ""; }; 9DB29B59282876B700AAC73D /* SitesController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SitesController.swift; sourceTree = ""; }; - 9DFB9CC0284E9BF500298526 /* AirtableSiteServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirtableSiteServiceTests.swift; sourceTree = ""; }; - 9DFB9CC42851345800298526 /* AirtableSessionFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirtableSessionFactory.swift; sourceTree = ""; }; 9DFF45C328C69AAD00D45C73 /* CloudinaryUploadResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudinaryUploadResponse.swift; sourceTree = ""; }; 9DFF6276282E63100008AEEF /* SupervisorsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupervisorsController.swift; sourceTree = ""; }; 9DFF6278282E64780008AEEF /* SpeciesController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeciesController.swift; sourceTree = ""; }; @@ -334,7 +300,6 @@ 851DAC2B262F2FA70087E1D4 /* Info.plist */, 851DAC35262F2FE30087E1D4 /* RecentSpeciesManagerTests.swift */, 851DAC39262F35B80087E1D4 /* TestHelpers.swift */, - 9DFB9CC0284E9BF500298526 /* AirtableSiteServiceTests.swift */, 9D47D981286F29E100F7B92F /* ProtectEarthCodableTests.swift */, 9D5F0623286F755600C8D4A6 /* ProtectEarthSupervisorServiceTests.swift */, 9D5F062E2874E5CA00C8D4A6 /* ProtectEarthSiteServiceTests.swift */, @@ -444,7 +409,6 @@ 85B839BE25B492550008E167 /* PHImageLoader.swift */, 851DAC1F262C55FD0087E1D4 /* RecentSpeciesManager.swift */, 85C781A025CC744E0034292D /* ScreenLockManager.swift */, - 85B839FD25B86F320008E167 /* URLImageLoader.swift */, ); path = Utilities; sourceTree = ""; @@ -484,13 +448,6 @@ 859F62C825C1C599005E61F7 /* Api */ = { isa = PBXGroup; children = ( - 859F62C225C1C44C005E61F7 /* AirtableImage.swift */, - 859F62BF25C1C436005E61F7 /* AirtableImageThumbnail.swift */, - 859F62CC25C1C61E005E61F7 /* AirtableSite.swift */, - 859F62C525C1C464005E61F7 /* AirtableSpecies.swift */, - 859F62D025C1C62D005E61F7 /* AirtableSupervisor.swift */, - 85A0EF8825A22FEE003CE744 /* AirtableTree.swift */, - 859F62F425C5A7DC005E61F7 /* Paginated.swift */, 9D47D97E286F293000F7B92F /* ProtectEarthSupervisor.swift */, 9D5F062A2874DD0400C8D4A6 /* ProtectEarthSite.swift */, 9DA84706287DB9D400B0BB3E /* ProtectEarthSpecies.swift */, @@ -504,7 +461,6 @@ 859F62CA25C1C5AA005E61F7 /* Database */ = { isa = PBXGroup; children = ( - 9D562D6A28C2BBDC00B66716 /* RemoteTree.swift */, 85B83A1725B881EC0008E167 /* LocalTree.swift */, 859F62D825C2215D005E61F7 /* Site.swift */, 859F62D525C22140005E61F7 /* Species.swift */, @@ -556,12 +512,8 @@ 9D5F0620286F5FEF00C8D4A6 /* AlamofireSessionFactory.swift */, 9D5F061D286F35C700C8D4A6 /* BearerTokenAuthenticationAdapter.swift */, 9D5F0616286F344E00C8D4A6 /* ProtectEarth */, - 9D5D5E2F284B698600F3AD3E /* Airtable */, 6CC985AA27F393630027C795 /* RetryingRequestInterceptor.swift */, - 853ABD7F25961EEA00144B0D /* Api.swift */, 85792A7C25B0A3E100BFDA96 /* Database.swift */, - 857123C7263997FD008EE027 /* AlamofireApi.swift */, - 857123CB26399B49008EE027 /* MockApi.swift */, 9D79A5A6283AE03100F0F96C /* SiteService.swift */, 9D5D5E27284B630D00F3AD3E /* SpeciesService.swift */, 9D5D5E2B284B66BB00F3AD3E /* SupervisorService.swift */, @@ -624,17 +576,6 @@ path = "UI Components"; sourceTree = ""; }; - 9D5D5E2F284B698600F3AD3E /* Airtable */ = { - isa = PBXGroup; - children = ( - 9D79A5AB283AE32C00F0F96C /* AirtableSiteService.swift */, - 9D5D5E29284B635900F3AD3E /* AirtableSpeciesService.swift */, - 9D5D5E2D284B670400F3AD3E /* AirtableSupervisorService.swift */, - 9DFB9CC42851345800298526 /* AirtableSessionFactory.swift */, - ); - path = Airtable; - sourceTree = ""; - }; 9D5F0616286F344E00C8D4A6 /* ProtectEarth */ = { isa = PBXGroup; children = ( @@ -793,7 +734,6 @@ 9DA84709287DBA3F00B0BB3E /* ProtectEarthSpeciesServiceTests.swift in Sources */, 851DAC3A262F35B80087E1D4 /* TestHelpers.swift in Sources */, 9D47D982286F29E100F7B92F /* ProtectEarthCodableTests.swift in Sources */, - 9DFB9CC1284E9BF500298526 /* AirtableSiteServiceTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -804,15 +744,12 @@ 85DC214325E0FCAD003F0721 /* DiskImageCache.swift in Sources */, 853415FA25CF0947006DDAC4 /* UIViewController.swift in Sources */, 85B839C325B4A5280008E167 /* BarButtonItem.swift in Sources */, - 853ABD8025961EEA00144B0D /* Api.swift in Sources */, - 85B839FE25B86F320008E167 /* URLImageLoader.swift in Sources */, 9D5F0618286F346800C8D4A6 /* ProtectEarthSupervisorService.swift in Sources */, 853415F325CEEE40006DDAC4 /* UploadSessionViewModel.swift in Sources */, 85B839B525B47C230008E167 /* CollectionListItem.swift in Sources */, 85B839D225B742510008E167 /* TextFieldModel.swift in Sources */, 85B839B125B47C0A0008E167 /* Action.swift in Sources */, 85B83A3325B9C9DC0008E167 /* String.swift in Sources */, - 859F62F525C5A7DC005E61F7 /* Paginated.swift in Sources */, 857BAD9D25B1DCDE005D7D35 /* Defaults.swift in Sources */, 85B839E425B753F10008E167 /* KeyboardLayoutGuide.swift in Sources */, 85CC02E6261B6DD90016E618 /* TableViewController.swift in Sources */, @@ -820,7 +757,6 @@ 85B839D625B74E960008E167 /* KeyboardAccessoryView.swift in Sources */, 85B839BF25B492550008E167 /* PHImageLoader.swift in Sources */, 85E0E05E25B33F8C009D8FC0 /* TappableButton.swift in Sources */, - 859F62CD25C1C61E005E61F7 /* AirtableSite.swift in Sources */, 85B839B825B47C3F0008E167 /* TreeCollectionViewCell.swift in Sources */, 85B839F825B86F0A0008E167 /* PHAssetManager.swift in Sources */, 85B839CB25B5F3EC0008E167 /* PHAsset.swift in Sources */, @@ -848,7 +784,6 @@ 85763A9425E29CE300CB4ED3 /* Logger.swift in Sources */, 9D562D6528B81BDE00B66716 /* ProtectEarthUpload.swift in Sources */, 9D562D7228C4129000B66716 /* CloudinarySessionFactory.swift in Sources */, - 857123CC26399B49008EE027 /* MockApi.swift in Sources */, 859F62D625C22140005E61F7 /* Species.swift in Sources */, 85B83A2325B9C1BC0008E167 /* NavigationViewController.swift in Sources */, 9DFF45C428C69AAD00D45C73 /* CloudinaryUploadResponse.swift in Sources */, @@ -857,14 +792,11 @@ 85792A9225B1D5A500BFDA96 /* ButtonModel.swift in Sources */, 85B839CE25B5F8F30008E167 /* UIAlertController.swift in Sources */, 851DAC20262C55FD0087E1D4 /* RecentSpeciesManager.swift in Sources */, - 859F62C025C1C436005E61F7 /* AirtableImageThumbnail.swift in Sources */, 859F62F225C5A7B8005E61F7 /* Cancellable.swift in Sources */, 9DFF6279282E64780008AEEF /* SpeciesController.swift in Sources */, 85CC0304261CC8FE0016E618 /* LocationManager.swift in Sources */, - 859F62D125C1C62D005E61F7 /* AirtableSupervisor.swift in Sources */, 9DB29B562821C28400AAC73D /* SettingsController.swift in Sources */, 9D5F0621286F5FEF00C8D4A6 /* AlamofireSessionFactory.swift in Sources */, - 9D5D5E2E284B670400F3AD3E /* AirtableSupervisorService.swift in Sources */, 9DA8470B287DBBC300B0BB3E /* ProtectEarthSpeciesService.swift in Sources */, 853ABD562596144900144B0D /* AppDelegate.swift in Sources */, 9D5F06332878ADF000C8D4A6 /* DataResponse.swift in Sources */, @@ -872,9 +804,7 @@ 9D5D5E2C284B66BB00F3AD3E /* SupervisorService.swift in Sources */, 85B83A1C25B8AC650008E167 /* EditLocalTreeViewModel.swift in Sources */, 85B83A2A25B9C5CB0008E167 /* JSONDecoder.swift in Sources */, - 85A0EF8925A22FEE003CE744 /* AirtableTree.swift in Sources */, 85B839E025B74FF50008E167 /* TextField.swift in Sources */, - 859F62C625C1C464005E61F7 /* AirtableSpecies.swift in Sources */, 853ABD8925961F9700144B0D /* Constants.swift in Sources */, 6CC985AB27F393630027C795 /* RetryingRequestInterceptor.swift in Sources */, 859F62D925C2215D005E61F7 /* Site.swift in Sources */, @@ -882,7 +812,6 @@ 9D47D97F286F293000F7B92F /* ProtectEarthSupervisor.swift in Sources */, 85E0E06225B35744009D8FC0 /* UIView.swift in Sources */, 851DAC1A262B4A9A0087E1D4 /* RecentSpecies.swift in Sources */, - 857123C8263997FD008EE027 /* AlamofireApi.swift in Sources */, 857BADA425B1FA87005D7D35 /* AddLocalTreeViewModel.swift in Sources */, 9DB29B5A282876B700AAC73D /* SitesController.swift in Sources */, 859F62E025C228AE005E61F7 /* KeyedContainer.swift in Sources */, @@ -893,7 +822,6 @@ 858A0F2725D8156100E12C2B /* TreeDetailsFlowViewController.swift in Sources */, 859F62DC25C2218B005E61F7 /* Supervisor.swift in Sources */, 85B839FB25B86F1A0008E167 /* ImageLoader.swift in Sources */, - 9D5D5E2A284B635900F3AD3E /* AirtableSpeciesService.swift in Sources */, 85B839EF25B866370008E167 /* GridCollectionViewLayout.swift in Sources */, 9DA8470D28844E5100B0BB3E /* ProtectEarthTreeService.swift in Sources */, 85B839E725B862B80008E167 /* CollectionViewDataSource.swift in Sources */, @@ -908,7 +836,6 @@ 9D562D6728B81D5400B66716 /* ProtectEarthIdentifier.swift in Sources */, 8563C263260BBC7A00752793 /* Secrets.swift in Sources */, 85792A8F25B1D59F00BFDA96 /* SyncProgress.swift in Sources */, - 9DFB9CC52851345800298526 /* AirtableSessionFactory.swift in Sources */, 85B83A0225B8735F0008E167 /* AnyImageLoader.swift in Sources */, 85CC02E9261B6E820016E618 /* TableListItem.swift in Sources */, 85CC02EC261B6EA90016E618 /* TextTableViewCell.swift in Sources */, @@ -916,11 +843,8 @@ 9DFF6277282E63100008AEEF /* SupervisorsController.swift in Sources */, 85763A9D25E2B0AB00CB4ED3 /* RotatingUIImagePickerController.swift in Sources */, 9D5F061B286F348F00C8D4A6 /* ProtectEarthSessionFactory.swift in Sources */, - 9D562D6B28C2BBDC00B66716 /* RemoteTree.swift in Sources */, - 859F62C325C1C44C005E61F7 /* AirtableImage.swift in Sources */, 857BADAC25B1FAAA005D7D35 /* UploadListFlowViewController.swift in Sources */, 859F62EF25C48FD8005E61F7 /* AlertModel.swift in Sources */, - 9D79A5AC283AE32C00F0F96C /* AirtableSiteService.swift in Sources */, 857123D02639C3AF008EE027 /* ClosureCancellable.swift in Sources */, 85B839BB25B4814F0008E167 /* ListSection.swift in Sources */, ); diff --git a/Tree Tracker/AppDelegate+Injection.swift b/Tree Tracker/AppDelegate+Injection.swift index 3b52a83..0fac83d 100644 --- a/Tree Tracker/AppDelegate+Injection.swift +++ b/Tree Tracker/AppDelegate+Injection.swift @@ -4,10 +4,7 @@ import UIKit extension Resolver: ResolverRegistering { - static let mock = Resolver(child: main) static let integrationTest = Resolver(child: main) - static let protectEarthApi = Resolver(child: integrationTest) - static let airtable = Resolver(child: main) public static func registerAllServices() { // register all components as singletons for lifetime of application @@ -17,7 +14,6 @@ extension Resolver: ResolverRegistering { // MARK: Base services register { Logger(output: .print) }.implements(Logging.self) register { Database(logger: resolve()) } - register { AlamofireApi(logger: resolve()) }.implements(Api.self) register { Defaults() } register { GRDBImageCache(logger: resolve()) } register { UIScreenLockManager() } @@ -25,35 +21,21 @@ extension Resolver: ResolverRegistering { register { RecentSpeciesManager(defaults: resolve(), strategy: .todayUsedSpecies) } // MARK: Services - //TODO: remove all Airtable services - airtable.register { AirtableSessionFactory(airtableBaseId: Constants.Airtable.baseId, - airtableApiKey: Constants.Airtable.apiKey, - httpRequestTimeoutSeconds: Constants.Http.requestTimeoutSeconds, - httpWaitsForConnectivity: true, - httpRetryDelaySeconds: Constants.Http.requestRetryDelaySeconds, - httpRetryLimit: Constants.Http.requestRetryLimit) as AlamofireSessionFactory } - - airtable.register { AirtableSiteService() as SiteService } - airtable.register { AirtableSpeciesService() as SpeciesService } - airtable.register { AirtableSupervisorService() as SupervisorService } - - // MARK: Protect Earth API specific services - protectEarthApi.register { ProtectEarthSessionFactory(baseUrl: Constants.Http.protectEarthApiBaseUrl, - apiVersion: Constants.Http.protectEarthApiVersion, - authToken: Secrets.protectEarthApiToken, - httpRequestTimeoutSeconds: Constants.Http.requestTimeoutSeconds, - httpWaitsForConnectivity: true, - httpRetryDelaySeconds: Constants.Http.requestRetryDelaySeconds, - httpRetryLimit: Constants.Http.requestRetryLimit) as AlamofireSessionFactory } - protectEarthApi.register { ProtectEarthSupervisorService() as SupervisorService } - protectEarthApi.register { ProtectEarthSiteService() as SiteService } - protectEarthApi.register { ProtectEarthSpeciesService() as SpeciesService } - protectEarthApi.register { ProtectEarthTreeService() as TreeService } - //TODO: sort out injection for prefixKey and naming - protectEarthApi.register { CloudinarySessionFactory(httpRequestTimeoutSeconds: Constants.Http.requestTimeoutSeconds, - httpWaitsForConnectivity: true, - httpRetryDelaySeconds: Constants.Http.requestRetryDelaySeconds, - httpRetryLimit: Constants.Http.requestRetryLimit) } + register { ProtectEarthSessionFactory(baseUrl: Constants.Http.protectEarthApiBaseUrl, + apiVersion: Constants.Http.protectEarthApiVersion, + authToken: Secrets.protectEarthApiToken, + httpRequestTimeoutSeconds: Constants.Http.requestTimeoutSeconds, + httpWaitsForConnectivity: true, + httpRetryDelaySeconds: Constants.Http.requestRetryDelaySeconds, + httpRetryLimit: Constants.Http.requestRetryLimit) as AlamofireSessionFactory } + register { ProtectEarthSupervisorService() as SupervisorService } + register { ProtectEarthSiteService() as SiteService } + register { ProtectEarthSpeciesService() as SpeciesService } + register { ProtectEarthTreeService() as TreeService } + register { CloudinarySessionFactory(httpRequestTimeoutSeconds: Constants.Http.requestTimeoutSeconds, + httpWaitsForConnectivity: true, + httpRetryDelaySeconds: Constants.Http.requestRetryDelaySeconds, + httpRetryLimit: Constants.Http.requestRetryLimit) } // MARK: Controllers register { SitesController() } @@ -61,30 +43,8 @@ extension Resolver: ResolverRegistering { register { SupervisorsController() } register { SettingsController(style: UITableView.Style.grouped) } - // MARK: test component registrations - mock.register { MockApi() as Api } - - integrationTest.register { AirtableSessionFactory(airtableBaseId: Secrets.testAirtableBaseId, - airtableApiKey: Secrets.testAirtableApiKey, - airtableTablePrefix: Secrets.testAirtableTableNamePrefix, - httpRequestTimeoutSeconds: Constants.Http.requestTimeoutSeconds, - httpWaitsForConnectivity: true, - httpRetryDelaySeconds: Constants.Http.requestRetryDelaySeconds, - httpRetryLimit: Constants.Http.requestRetryLimit) as AlamofireSessionFactory } - - if CommandLine.arguments.contains("--mock-server") { - Resolver.root = Resolver.mock - } - if CommandLine.arguments.contains("--integration-test") { Resolver.root = Resolver.integrationTest } - - if CommandLine.arguments.contains("--airtable") { - Resolver.root = Resolver.airtable - } - - // default to using Protect Earth API - Resolver.root = Resolver.protectEarthApi } } diff --git a/Tree Tracker/Constants.swift b/Tree Tracker/Constants.swift index 0a9d44f..e4a4ca9 100644 --- a/Tree Tracker/Constants.swift +++ b/Tree Tracker/Constants.swift @@ -1,14 +1,6 @@ import Foundation enum Constants { - enum Airtable { - static let apiKey = Secrets.airtableApiKey - static let baseId = Secrets.airtableBaseId - static let treesTable = Secrets.airtableTreesTableName - static let sitesTable = Secrets.airtableSitesTableName - static let supervisorsTable = Secrets.airtableSupervisorsTableName - static let speciesTable = Secrets.airtableSpeciesTableName - } enum Cloudinary { static let cloudName = Secrets.cloudinaryCloudName static let uploadPresetName = Secrets.cloudinaryUploadPresetName diff --git a/Tree Tracker/Info.plist b/Tree Tracker/Info.plist index 0503426..4aa4399 100644 --- a/Tree Tracker/Info.plist +++ b/Tree Tracker/Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 0.10.2 + 0.10.3 CFBundleVersion $(CURRENT_PROJECT_VERSION) ITSAppUsesNonExemptEncryption diff --git a/Tree Tracker/Models/Api/AirtableImage.swift b/Tree Tracker/Models/Api/AirtableImage.swift deleted file mode 100644 index 12036a0..0000000 --- a/Tree Tracker/Models/Api/AirtableImage.swift +++ /dev/null @@ -1,32 +0,0 @@ -import Foundation - -struct AirtableImage: Codable { - let url: String - let thumbnailUrl: String? - - enum CodingKeys: String, CodingKey { - case url - case thumbnailUrl = "thumbnails" - } - - init(url: String, thumbnailUrl: String?) { - self.url = url - self.thumbnailUrl = thumbnailUrl - } - - init(from decoder: Decoder) throws { - var root = try decoder.unkeyedContainer() - let container = try root.nestedContainer(keyedBy: CodingKeys.self) - - url = try container.decode(String.self, forKey: .url) - thumbnailUrl = (try? container.decodeIfPresent(AirtableImageThumbnail.self, forKey: .thumbnailUrl))?.url - } - - func encode(to encoder: Encoder) throws { - var root = encoder.unkeyedContainer() - var container = root.nestedContainer(keyedBy: CodingKeys.self) - - try container.encode(url, forKey: .url) - try container.encodeIfPresent(thumbnailUrl, forKey: .thumbnailUrl) - } -} diff --git a/Tree Tracker/Models/Api/AirtableImageThumbnail.swift b/Tree Tracker/Models/Api/AirtableImageThumbnail.swift deleted file mode 100644 index abe5acb..0000000 --- a/Tree Tracker/Models/Api/AirtableImageThumbnail.swift +++ /dev/null @@ -1,29 +0,0 @@ -import Foundation - -struct AirtableImageThumbnail: Codable { - let url: String - - enum CodingKeys: String, CodingKey { - case small - case large - case url - } - - init(url: String) { - self.url = url - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let small = try container.nestedContainer(keyedBy: CodingKeys.self, forKey: .large) - - url = try small.decode(String.self, forKey: .url) - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - var small = container.nestedContainer(keyedBy: CodingKeys.self, forKey: .large) - - try small.encode(url, forKey: .url) - } -} diff --git a/Tree Tracker/Models/Api/AirtableSite.swift b/Tree Tracker/Models/Api/AirtableSite.swift deleted file mode 100644 index 6a17769..0000000 --- a/Tree Tracker/Models/Api/AirtableSite.swift +++ /dev/null @@ -1,29 +0,0 @@ -import Foundation - -struct AirtableSite: Decodable { - let id: String - let name: String - - enum CodingKeys: String, CodingKey { - case id = "id" - case name = "Name" - case fields - } - - init(id: String, name: String) { - self.id = id - self.name = name - } - - init(from decoder: Decoder) throws { - let root = try decoder.container(keyedBy: CodingKeys.self) - let container = try root.nestedContainer(keyedBy: CodingKeys.self, forKey: .fields) - - id = try root.decode(String.self, forKey: .id) - name = try container.decode(String.self, forKey: .name) - } - - func toSite() -> Site { - return .init(id: id, name: name) - } -} diff --git a/Tree Tracker/Models/Api/AirtableSpecies.swift b/Tree Tracker/Models/Api/AirtableSpecies.swift deleted file mode 100644 index b0de5f1..0000000 --- a/Tree Tracker/Models/Api/AirtableSpecies.swift +++ /dev/null @@ -1,29 +0,0 @@ -import Foundation - -struct AirtableSpecies: Decodable { - let id: String - let name: String - - enum CodingKeys: String, CodingKey { - case id = "id" - case name = "Name" - case fields - } - - init(id: String, name: String) { - self.id = id - self.name = name - } - - init(from decoder: Decoder) throws { - let root = try decoder.container(keyedBy: CodingKeys.self) - let container = try root.nestedContainer(keyedBy: CodingKeys.self, forKey: .fields) - - id = try root.decode(String.self, forKey: .id) - name = try container.decode(String.self, forKey: .name) - } - - func toSpecies() -> Species { - return .init(id: id, name: name) - } -} diff --git a/Tree Tracker/Models/Api/AirtableSupervisor.swift b/Tree Tracker/Models/Api/AirtableSupervisor.swift deleted file mode 100644 index 9880bda..0000000 --- a/Tree Tracker/Models/Api/AirtableSupervisor.swift +++ /dev/null @@ -1,29 +0,0 @@ -import Foundation - -struct AirtableSupervisor: Decodable { - let id: String - let name: String - - enum CodingKeys: String, CodingKey { - case id = "id" - case name = "Name" - case fields - } - - init(id: String, name: String) { - self.id = id - self.name = name - } - - init(from decoder: Decoder) throws { - let root = try decoder.container(keyedBy: CodingKeys.self) - let container = try root.nestedContainer(keyedBy: CodingKeys.self, forKey: .fields) - - id = try root.decode(String.self, forKey: .id) - name = try container.decode(String.self, forKey: .name) - } - - func toSupervisor() -> Supervisor { - return .init(id: id, name: name) - } -} diff --git a/Tree Tracker/Models/Api/AirtableTree.swift b/Tree Tracker/Models/Api/AirtableTree.swift deleted file mode 100644 index fe77d78..0000000 --- a/Tree Tracker/Models/Api/AirtableTree.swift +++ /dev/null @@ -1,132 +0,0 @@ -import Foundation - -struct AirtableTree: Decodable { - let id: Int - let supervisor: String - let species: String - let site: String - let notes: String? - let coordinates: String? - let what3words: String? - let imageMd5: String? - let createDate: Date? - let uploadDate: Date? - var imageUrl: String? - var thumbnailUrl: String? - - enum CodingKeys: String, CodingKey { - case id = "ID" - case supervisor = "Supervisor" - case species = "Species" - case site = "Sites" - case notes = "Notes" - case coordinates = "Coordinates" - case what3words = "What3Words" - case image = "Image" - case imageMd5 = "ImageSignature" - case createDate = "CreatedDate" - case uploadDate = "UploadedDate" - case fields - } - - init(id: Int, supervisor: String, species: String, site: String, notes: String?, coordinates: String?, what3words: String?, imageUrl: String?, thumbnailUrl: String?, imageMd5: String?, uploadDate: Date?, createDate: Date?) { - self.id = id - self.supervisor = supervisor - self.species = species - self.site = site - self.notes = notes - self.coordinates = coordinates - self.what3words = what3words - self.imageUrl = imageUrl - self.thumbnailUrl = thumbnailUrl - self.imageMd5 = imageMd5 - self.uploadDate = uploadDate - self.createDate = createDate - } - - init(from decoder: Decoder) throws { - let root = try decoder.container(keyedBy: CodingKeys.self) - let container = try root.nestedContainer(keyedBy: CodingKeys.self, forKey: .fields) - - id = try container.decode(Int.self, forKey: .id) - site = try container.decodeSingleStringInArray(forKey: .site) - supervisor = try container.decodeSingleStringInArray(forKey: .supervisor) - species = try container.decodeSingleStringInArray(forKey: .species) - notes = try container.decodeIfPresent(String.self, forKey: .notes) - coordinates = try container.decodeIfPresent(String.self, forKey: .coordinates) - what3words = try container.decodeIfPresent(String.self, forKey: .what3words) - imageMd5 = try container.decodeIfPresent(String.self, forKey: .imageMd5) - uploadDate = try container.decodeIfPresent(Date.self, forKey: .uploadDate) - createDate = try container.decodeIfPresent(Date.self, forKey: .createDate) - - do { - let image = try container.decodeIfPresent(AirtableImage.self, forKey: .image) - imageUrl = image?.url - thumbnailUrl = image?.thumbnailUrl - } catch { - CurrentEnvironment.logger.log("Error decoding airtable image: \(error)") - imageUrl = nil - thumbnailUrl = nil - } - } - - func toRemoteTree(sentFromThisDevice: Bool) -> RemoteTree { - return RemoteTree(id: id, supervisor: supervisor, species: species, site: site, notes: notes, coordinates: coordinates, what3words: what3words, imageUrl: imageUrl, thumbnailUrl: thumbnailUrl, imageMd5: imageMd5, createDate: createDate, uploadDate: uploadDate, sentFromThisDevice: sentFromThisDevice) - } -} - -struct AirtableTreeEncodable: Encodable { - let supervisor: String - let species: String - let site: String - let notes: String? - let coordinates: String? - let what3words: String? - let imageMd5: String? - let createDate: Date? - let uploadDate: Date? - var imageUrl: String? - - enum CodingKeys: String, CodingKey { - case supervisor = "Supervisor" - case species = "Species" - case site = "Sites" - case notes = "Notes" - case coordinates = "Coordinates" - case what3words = "What3Words" - case image = "Image" - case imageMd5 = "ImageSignature" - case createDate = "CreatedDate" - case uploadDate = "UploadedDate" - case fields - } - - init(supervisor: String, species: String, site: String, notes: String?, coordinates: String?, what3words: String?, imageUrl: String?, imageMd5: String?, uploadDate: Date?, createDate: Date?) { - self.supervisor = supervisor - self.species = species - self.site = site - self.notes = notes - self.coordinates = coordinates - self.what3words = what3words - self.imageUrl = imageUrl - self.imageMd5 = imageMd5 - self.uploadDate = uploadDate - self.createDate = createDate - } - - func encode(to encoder: Encoder) throws { - var root = encoder.container(keyedBy: CodingKeys.self) - var container = root.nestedContainer(keyedBy: CodingKeys.self, forKey: .fields) - - try container.encodeSingleStringInArray(species, forKey: .species) - try container.encodeSingleStringInArray(supervisor, forKey: .supervisor) - try container.encodeSingleStringInArray(site, forKey: .site) - try container.encode(notes, forKey: .notes) - try container.encode(coordinates, forKey: .coordinates) - try container.encode(what3words, forKey: .what3words) - try container.encode(imageMd5, forKey: .imageMd5) - try container.encode(imageUrl.map { AirtableImage(url: $0, thumbnailUrl: nil) }, forKey: .image) - try container.encode(uploadDate, forKey: .uploadDate) - try container.encode(createDate, forKey: .createDate) - } -} diff --git a/Tree Tracker/Models/Api/Paginated.swift b/Tree Tracker/Models/Api/Paginated.swift deleted file mode 100644 index 6ee5647..0000000 --- a/Tree Tracker/Models/Api/Paginated.swift +++ /dev/null @@ -1,6 +0,0 @@ -import Foundation - -struct Paginated: Decodable { - let offset: String? - let records: [Model] -} diff --git a/Tree Tracker/Models/Database/LocalTree.swift b/Tree Tracker/Models/Database/LocalTree.swift index aec5ebf..0309027 100644 --- a/Tree Tracker/Models/Database/LocalTree.swift +++ b/Tree Tracker/Models/Database/LocalTree.swift @@ -22,12 +22,4 @@ struct LocalTree: Codable, FetchableRecord, PersistableRecord, TableRecord { case coordinates case imageMd5 } - - func toAirtableTree(imageUrl: String) -> AirtableTreeEncodable { - return AirtableTreeEncodable(supervisor: supervisor, species: species, site: site, notes: nil, coordinates: coordinates, what3words: nil, imageUrl: imageUrl, imageMd5: imageMd5, uploadDate: Date(), createDate: createDate) - } - - func toAirtableTree(id: Int, imageUrl: String) -> AirtableTree { - return AirtableTree(id: id, supervisor: supervisor, species: species, site: site, notes: nil, coordinates: coordinates, what3words: nil, imageUrl: imageUrl, thumbnailUrl: imageUrl, imageMd5: imageMd5, uploadDate: .now, createDate: .now) - } } diff --git a/Tree Tracker/Models/Database/RemoteTree.swift b/Tree Tracker/Models/Database/RemoteTree.swift deleted file mode 100644 index 20bcc69..0000000 --- a/Tree Tracker/Models/Database/RemoteTree.swift +++ /dev/null @@ -1,35 +0,0 @@ -import Foundation -import GRDB - -struct RemoteTree: Codable, FetchableRecord, PersistableRecord, TableRecord, Identifiable { - let id: Int - let supervisor: String - let species: String - let site: String - let notes: String? - var coordinates: String? - let what3words: String? - var imageUrl: String? - var thumbnailUrl: String? - let imageMd5: String? - var createDate: Date? - var uploadDate: Date? - var sentFromThisDevice: Bool - - // Because these are not accessible when synthesized by Swift, please do not remove it - enum CodingKeys: String, CodingKey { - case id - case supervisor - case species - case site - case notes - case coordinates - case what3words - case imageUrl - case thumbnailUrl - case imageMd5 - case createDate - case uploadDate - case sentFromThisDevice - } -} diff --git a/Tree Tracker/Screens/Upload/UploadViewModel.swift b/Tree Tracker/Screens/Upload/UploadViewModel.swift index 2b1b2bf..1cea807 100644 --- a/Tree Tracker/Screens/Upload/UploadViewModel.swift +++ b/Tree Tracker/Screens/Upload/UploadViewModel.swift @@ -24,7 +24,6 @@ final class UploadViewModel: CollectionViewModel { var rightNavigationButtonsPublisher: Published<[NavigationBarButtonModel]>.Publisher { $rightNavigationButtons } var dataPublisher: Published<[ListSection]>.Publisher { $data } - @Injected private var api: Api @Injected private var database: Database @Injected private var treeService: TreeService diff --git a/Tree Tracker/Services/Airtable/AirtableSessionFactory.swift b/Tree Tracker/Services/Airtable/AirtableSessionFactory.swift deleted file mode 100644 index e8fcd1f..0000000 --- a/Tree Tracker/Services/Airtable/AirtableSessionFactory.swift +++ /dev/null @@ -1,72 +0,0 @@ -import Foundation -import Alamofire - -/* - Provides request interception, including retry and authentication, for Airtable requests - */ -class AirtableSessionFactory: AlamofireSessionFactory { - - private var session: Session? - private var airtableBaseId: String - private var airtableApiKey: String - private var httpRequestTimeoutSeconds: TimeInterval - private var httpWaitsForConnectivity: Bool - private var httpRetryDelaySeconds: Int - private var httpRetryLimit: Int - private var airtableTablePrefix: String - - init(airtableBaseId: String, - airtableApiKey: String, - airtableTablePrefix: String = "", - httpRequestTimeoutSeconds: TimeInterval, - httpWaitsForConnectivity: Bool, - httpRetryDelaySeconds: Int, - httpRetryLimit: Int) { - self.airtableBaseId = airtableBaseId - self.airtableApiKey = airtableApiKey - self.airtableTablePrefix = airtableTablePrefix - self.httpRequestTimeoutSeconds = httpRequestTimeoutSeconds - self.httpWaitsForConnectivity = httpWaitsForConnectivity - self.httpRetryDelaySeconds = httpRetryDelaySeconds - self.httpRetryLimit = httpRetryLimit - } - - func get() -> Session { - if session == nil { - let sessionConfig = URLSessionConfiguration.af.default - sessionConfig.timeoutIntervalForRequest = httpRequestTimeoutSeconds - sessionConfig.waitsForConnectivity = httpWaitsForConnectivity - - let interceptor = Interceptor(adapter: BearerTokenAuthenticationAdapter(airtableApiKey), - retrier: RetryingRequestInterceptor(retryDelaySecs: httpRetryDelaySeconds, - maxRetries: httpRetryLimit)) - - session = Session(configuration: sessionConfig, - interceptor: interceptor) - } - return session! - } - - func baseUrl(adding: String) -> URL { - var result = URL(string: "https://api.airtable.com/v0/\(airtableBaseId)")! - result.appendPathComponent(adding) - return result - } - - func getSitesUrl() -> URL { - return baseUrl(adding: "\(airtableTablePrefix)\(Constants.Airtable.sitesTable)") - } - - func getSpeciesUrl() -> URL { - return baseUrl(adding: "\(airtableTablePrefix)\(Constants.Airtable.speciesTable)") - } - - func getSupervisorUrl() -> URL { - return baseUrl(adding: "\(airtableTablePrefix)\(Constants.Airtable.supervisorsTable)") - } - - func getTreeUrl() -> URL { - fatalError("getTreeUrl not implemented!") - } - -} diff --git a/Tree Tracker/Services/Airtable/AirtableSiteService.swift b/Tree Tracker/Services/Airtable/AirtableSiteService.swift deleted file mode 100644 index 95ffcb9..0000000 --- a/Tree Tracker/Services/Airtable/AirtableSiteService.swift +++ /dev/null @@ -1,62 +0,0 @@ -import Foundation -import Resolver -import Alamofire - -class AirtableSiteService: SiteService { - - @Injected private var database: Database - @Injected private var sessionFactory: AlamofireSessionFactory - - // MARK: data publisher - // See https://swiftsenpai.com/swift/define-protocol-with-published-property-wrapper/ - @Published var sites: [Site] = [] - var sitesPublisher: Published<[Site]>.Publisher { $sites } - - // MARK: business logic - init() { - self.sync() { _ in } // fire and forget - } - - // Synchronise local cache with remote datastore - func sync(completion: @escaping (Result) -> Void) { - let request = getSession().request(sessionFactory.getSitesUrl(), - method: .get, - encoding: URLEncoding.queryString) - - request.validate().responseDecodable(decoder: JSONDecoder._iso8601ms) { [weak self] (response: DataResponse, AFError>) in - // TODO: Handle multiple pages (where number of sites > 100) - switch response.result { - case .success: - do { - let result = try response.result.get() - self?.sites.removeAll() - result.records.forEach { airtableSite in - self?.sites.append(airtableSite.toSite()) - } - self?.database.replace(self!.sites) { - completion(.success(true)) - } - } catch { - print("Unexpected error: \(error).") - } - case .failure: - completion(.failure(ProtectEarthError.remoteError(errorCode: response.error!.responseCode!, - errorMessage: (response.error!.errorDescription!)))) - } - } - } - - // Return sites from local cache, adding to buffer - func fetchAll(completion: @escaping (Result<[Site], ProtectEarthError>) -> Void) { - database.fetchAll(Site.self) { [weak self] sites in - self?.sites.removeAll() - sites.forEach() { self?.sites.append($0) } - completion(Result.success(self!.sites)) - } - } - - private func getSession() -> Session { - sessionFactory.get() - } - -} diff --git a/Tree Tracker/Services/Airtable/AirtableSpeciesService.swift b/Tree Tracker/Services/Airtable/AirtableSpeciesService.swift deleted file mode 100644 index d4eca6b..0000000 --- a/Tree Tracker/Services/Airtable/AirtableSpeciesService.swift +++ /dev/null @@ -1,69 +0,0 @@ -import Foundation -import Resolver -import Alamofire - -class AirtableSpeciesService: SpeciesService { - - @Injected private var database: Database - @Injected private var sessionFactory: AlamofireSessionFactory - - // MARK: data publisher - // See https://swiftsenpai.com/swift/define-protocol-with-published-property-wrapper/ - @Published var species: [Species] = [] - var speciesPublisher: Published<[Species]>.Publisher { $species } - - // MARK: business logic - init() { - self.sync() { _ in } // fire and forget - } - - // Synchronise local cache with remote datastore - func sync(completion: @escaping (Result) -> Void) { - let request = getSession().request(sessionFactory.getSpeciesUrl(), - method: .get, - encoding: URLEncoding.queryString) - - request.validate().responseDecodable(decoder: JSONDecoder._iso8601ms) { [weak self] (response: DataResponse, AFError>) in - // TODO: Handle multiple pages (where number of species > 100) - switch response.result { - case .success: - do { - let result = try response.result.get() - self?.species.removeAll() - result.records.forEach { airtableSpecies in - self?.species.append(airtableSpecies.toSpecies()) - } - self?.database.replace(self!.species) { - completion(.success(true)) - } - } catch { - print("Unexpected error: \(error).") - } - case .failure: - completion(.failure(ProtectEarthError.remoteError(errorCode: response.error!.responseCode!, - errorMessage: (response.error!.errorDescription!)))) - } - } - } - - // Return species from local cache - func fetchAll(completion: @escaping (Result<[Species], ProtectEarthError>) -> Void) { - database.fetchAll(Species.self) { [weak self] species in - self?.species.removeAll() - species.forEach() { self?.species.append($0) } - completion(Result.success(self!.species)) - } - } - - // Add a species to remote and trigger a sync to update local cache - func addSpecies(name: String, completion: @escaping (Result) -> Void) { - fatalError("addSpecies(name:, completion:) has not been implemented") - } - - private func getSession() -> Session { - sessionFactory.get() - } - -} - - diff --git a/Tree Tracker/Services/Airtable/AirtableSupervisorService.swift b/Tree Tracker/Services/Airtable/AirtableSupervisorService.swift deleted file mode 100644 index 4bf5e5e..0000000 --- a/Tree Tracker/Services/Airtable/AirtableSupervisorService.swift +++ /dev/null @@ -1,67 +0,0 @@ -import Foundation -import Resolver -import Alamofire - -class AirtableSupervisorService: SupervisorService { - - @Injected private var database: Database - @Injected private var sessionFactory: AlamofireSessionFactory - - // MARK: data publisher - // See https://swiftsenpai.com/swift/define-protocol-with-published-property-wrapper/ - @Published var supervisors: [Supervisor] = [] - var supervisorPublisher: Published<[Supervisor]>.Publisher { $supervisors } - - // MARK: business logic - init() { - self.sync() { _ in } // fire and forget - } - - // Synchronise local cache with remote datastore - func sync(completion: @escaping (Result) -> Void) { - let request = getSession().request(sessionFactory.getSupervisorUrl(), - method: .get, - encoding: URLEncoding.queryString) - - request.validate().responseDecodable(decoder: JSONDecoder._iso8601ms) { [weak self] (response: DataResponse, AFError>) in - // TODO: Handle multiple pages (where number of entries > 100) - switch response.result { - case .success: - do { - let result = try response.result.get() - self?.supervisors.removeAll() - result.records.forEach { record in - self?.supervisors.append(record.toSupervisor()) - } - self?.database.replace(self!.supervisors) { - completion(.success(true)) - } - } catch { - print("Unexpected error: \(error).") - } - case .failure: - completion(.failure(ProtectEarthError.remoteError(errorCode: response.error!.responseCode!, - errorMessage: (response.error!.errorDescription!)))) - } - } - } - - // Return data from local cache, adding to buffer - func fetchAll(completion: @escaping (Result<[Supervisor], ProtectEarthError>) -> Void) { - database.fetchAll(Supervisor.self) { [weak self] supervisor in - self?.supervisors.removeAll() - supervisor.forEach() { self?.supervisors.append($0) } - completion(Result.success(self!.supervisors)) - } - } - - // Add a record to remote and trigger a sync to update local cache - func addSupervisor(name: String, completion: @escaping (Result) -> Void) { - fatalError("addSupervisor(name:, completion:) has not been implemented") - } - - private func getSession() -> Session { - sessionFactory.get() - } - -} diff --git a/Tree Tracker/Services/AlamofireApi.swift b/Tree Tracker/Services/AlamofireApi.swift deleted file mode 100644 index 3eea6c1..0000000 --- a/Tree Tracker/Services/AlamofireApi.swift +++ /dev/null @@ -1,186 +0,0 @@ -import Foundation -import Alamofire -import class UIKit.UIImage -import RollbarNotifier - -fileprivate extension LogCategory { - static var api = LogCategory(name: "Api") -} - -final class AlamofireApi: Api { - fileprivate struct Config { - static let baseUrl = URL(string: "https://api.airtable.com/v0/\(Constants.Airtable.baseId)")! - static let treesUrl = baseUrl.appendingPathComponent(Constants.Airtable.treesTable) - static let supervisorsUrl = baseUrl.appendingPathComponent(Constants.Airtable.supervisorsTable) - static let sitesUrl = baseUrl.appendingPathComponent(Constants.Airtable.sitesTable) - static let speciesUrl = baseUrl.appendingPathComponent(Constants.Airtable.speciesTable) - static let headers = HTTPHeaders(["Authorization": "Bearer \(Constants.Airtable.apiKey)"]) - - enum Cloudinary { - static let uploadUrl = URL(string: "https://api.cloudinary.com/v1_1/\(Constants.Cloudinary.cloudName)/image/upload")! - } - } - - private let session: Session - private let logger: Logging - private var imageLoaders = [String: PHImageLoader]() - - init(logger: Logging = CurrentEnvironment.logger) { - self.logger = logger - - let sessionConfig = URLSessionConfiguration.af.default - sessionConfig.timeoutIntervalForRequest = Constants.Http.requestTimeoutSeconds - sessionConfig.waitsForConnectivity = Constants.Http.requestWaitsForConnectivity - - self.session = Session(configuration: sessionConfig, - interceptor: RetryingRequestInterceptor(retryDelaySecs: Constants.Http.requestRetryDelaySeconds, - maxRetries: Constants.Http.requestRetryLimit)) - - } - - @available(*, deprecated, message: "Replaced by ProtectEarthTreeService") - func upload(tree: LocalTree, progress: @escaping (Double) -> Void = { _ in }, completion: @escaping (Result) -> Void) -> Cancellable { - let upload = ImageUpload(tree: tree, logger: logger) - upload.upload(tree: tree, progress: progress, session: session, completion: completion) - - return upload - } - - func loadImage(url: String, completion: @escaping (UIImage?) -> Void) { - let request = session.request(url, method: .get, headers: Config.headers) - - request.validate().responseData { data in - completion(data.data.flatMap(UIImage.init(data:))) - } - } -} - -@available(*, deprecated, message: "Replaced by ProtectEarthTreeService") -final class ImageUpload: Cancellable { - private let tree: LocalTree - private let imageLoader: PHImageLoader - private let logger: Logging - - private var request: Request? - private var progress: ((Double) -> Void)? - private var isCancelled = false - - init(tree: LocalTree, logger: Logging = CurrentEnvironment.logger) { - self.tree = tree - self.imageLoader = PHImageLoader(phImageId: tree.phImageId) - self.logger = logger - } - - func cancel() { - guard !isCancelled else { return } - - isCancelled = true - request?.cancel() - } - - func upload(tree: LocalTree, progress: @escaping (Double) -> Void = { _ in }, session: Session, completion: @escaping (Result) -> Void) { - self.progress = progress - - // issue-48 - upload images at 1150x1530px size - imageLoader.loadUploadImage { [weak self] image in - guard self?.isCancelled != true else { - completion(.failure(AFError.explicitlyCancelled)) - return - } - - guard let image = image else { - completion(.failure(AFError.explicitlyCancelled)) - return - } - - self?.progress?(0.2) - - self?.request = self?.upload(image: image, session: session) { result in - switch result { - case let .success((url, md5)): - var newTree = tree - newTree.imageMd5 = md5 - self?.request = self?.upload(tree: newTree, imageUrl: url, session: session, completion: completion) - case let .failure(error): - Rollbar.errorError(error, - data: ["md5": tree.imageMd5 ?? "", - "phImageId": tree.phImageId, - "coordinates": tree.coordinates ?? "", - "supervisor": tree.supervisor, - "site": tree.site], - context: "Fetching upload image for tree") - completion(.failure(error)) - } - } - } - } - - private func upload(image: UIImage, session: Session, completion: @escaping (Result<(String, String), AFError>) -> Void) -> Request? { - logger.log(.api, "Uploading image to Cloudinary...") - guard let data = image.jpegData(compressionQuality: 0.8) else { - logger.log(.api, "No pngData for the image, bailing") - Rollbar.errorMessage("No pngData for image, upload will be skipped") - completion(.failure(.explicitlyCancelled)) - return nil - } - - let md5 = data.md5() - let request = session - .upload( - multipartFormData: { formData in - formData.append(data, withName: "file", fileName: "image.jpg", mimeType: "image/jpg") - formData.append(Constants.Cloudinary.uploadPresetName.data(using: .utf8)!, withName: "upload_preset") - }, - to: AlamofireApi.Config.Cloudinary.uploadUrl, - method: .post - ).uploadProgress { progress in - self.progress?(0.2 + 0.75 * progress.fractionCompleted) - } - - return request.validate().responseJSON { [weak self] response in - switch response.result { - case let .failure(error): - Rollbar.errorError(error, - data: [:], - context: response.dataAsUTF8String()) - self?.logger.log(.api, "Error when uploading image: \(response.dataAsUTF8String())") - completion(.failure(error)) - case let .success(json as [String: Any]): - let url = json["secure_url"] as? String - - if let url = url { - completion(.success((url, md5))) - } else { - fallthrough - } - default: - Rollbar.errorMessage("Error while parsing JSON", - data: [:], - context: response.dataAsUTF8String()) - self?.logger.log(.api, "Error when parsing json: \(response.dataAsUTF8String())") - completion(.failure(.explicitlyCancelled)) - } - } - } - - private func upload(tree: LocalTree, imageUrl: String, session: Session, completion: @escaping (Result) -> Void) -> Request? { - let airtableTree = tree.toAirtableTree(imageUrl: imageUrl) - let request = session.request(AlamofireApi.Config.treesUrl, method: .post, parameters: airtableTree, encoder: JSONParameterEncoder(encoder: ._iso8601ms), headers: AlamofireApi.Config.headers) - - return request.validate().responseDecodable(decoder: JSONDecoder._iso8601ms) { [weak self] (response: DataResponse) in - self?.progress?(1.0) - - switch response.result { - case let .success(tree): - self?.logger.log(.api, "Tree uploaded!") - completion(.success(tree)) - case let .failure(error): - Rollbar.errorError(error, - data: [:], - context: response.dataAsUTF8String()) - self?.logger.log(.api, "Error when creating Airtable record: \(response.dataAsUTF8String())") - completion(.failure(error)) - } - } - } -} diff --git a/Tree Tracker/Services/Api.swift b/Tree Tracker/Services/Api.swift deleted file mode 100644 index a686eb4..0000000 --- a/Tree Tracker/Services/Api.swift +++ /dev/null @@ -1,9 +0,0 @@ -import Foundation -import Alamofire -import class UIKit.UIImage - -protocol Api { - @available(*, deprecated, message: "Replaced by ProtectEarthTreeService") - func upload(tree: LocalTree, progress: @escaping (Double) -> Void, completion: @escaping (Result) -> Void) -> Cancellable - func loadImage(url: String, completion: @escaping (UIImage?) -> Void) -} diff --git a/Tree Tracker/Services/Database.swift b/Tree Tracker/Services/Database.swift index a203dc4..6509f19 100644 --- a/Tree Tracker/Services/Database.swift +++ b/Tree Tracker/Services/Database.swift @@ -119,35 +119,6 @@ final class Database { } } - @available(*, deprecated, message: "Unused since removal of upload history view") - func save(_ trees: [AirtableTree], sentFromThisDevice: Bool) { - do { - try dbQueue?.write { db in - trees.forEach { tree in - let tree = tree.toRemoteTree(sentFromThisDevice: sentFromThisDevice) - do { - let potentialTree = try RemoteTree - .filter(key: tree.id) - .fetchOne(db) - - if potentialTree == nil { - try tree.insert(db) - let count = try? RemoteTree.fetchCount(db) - logger.log(.database, "Successfully added a remote tree to database. Current count: \(count ?? 0)") - } else { - logger.log(.database, "Error when adding remote tree to DB. Found a tree with the same id, bailing.") - } - } catch { - logger.log(.database, "Tree: \(tree)") - logger.log(.database, "Error when adding remote tree to DB. \(error)") - } - } - } - } catch { - logger.log(.database, "Error when adding remote tree to DB. \(error)") - } - } - func save(_ trees: [LocalTree]) { do { try dbQueue?.write { db in diff --git a/Tree Tracker/Services/MockApi.swift b/Tree Tracker/Services/MockApi.swift deleted file mode 100644 index 73b518e..0000000 --- a/Tree Tracker/Services/MockApi.swift +++ /dev/null @@ -1,82 +0,0 @@ -import Foundation -import Alamofire -import class UIKit.UIImage - -fileprivate extension UIImage { - static var mockTree1: UIImage { UIImage(named: "mockTree1")! } - static var mockTree2: UIImage { UIImage(named: "mockTree2")! } - static var mockTree3: UIImage { UIImage(named: "mockTree3")! } -} - -final class MockApi: Api { - let errorRate = 5 // percentage of error responses - let delayRange = 0.0...0.3 // the delay of a response will be randomly selected from this range - - private(set) var treesPlanted = [AirtableTree]() - private(set) var species: [AirtableSpecies] = [.init(id: "1", name: "Alder"), .init(id: "2", name: "Bird Cherry"), .init(id: "3", name: "Elder")] - private(set) var sites: [AirtableSite] = [.init(id: "1", name: "Howard Court"), .init(id: "2", name: "Donkeywell Farm")] - private(set) var supervisors: [AirtableSupervisor] = [.init(id: "1", name: "Josh Hopkins")] - private var images = [UIImage.mockTree1, .mockTree2, .mockTree3] - - func upload(tree: LocalTree, progress: @escaping (Double) -> Void, completion: @escaping (Result) -> Void) -> Cancellable { - var isCancelled = false - - let maxEntries = 5 - var currentEntry = 0 - delayUntil { - if isCancelled { - return false - } - - progress(Double(currentEntry) * 0.2) - let finishedUpload = currentEntry >= (maxEntries - 1) - - if finishedUpload { - let id = Int(Date.now.timeIntervalSince1970 * 100) - 161962124000 - completion(.success(tree.toAirtableTree(id: id, imageUrl: "https://google.com/\(id)"))) - } else { - currentEntry += 1 - } - return !finishedUpload - } - - return ClosureCancellable { - isCancelled = true - completion(.failure(.explicitlyCancelled)) - } - } - - func loadImage(url: String, completion: @escaping (UIImage?) -> Void) { - delay { - completion(self.images.randomElement()) - } - } - - private func delay(completion: @escaping () -> Void) { - let delay = TimeInterval.random(in: delayRange) - - DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + delay) { - completion() - } - } - - private func delayUntil(completion: @escaping () -> Bool) { - delay { [weak self] in - let shouldContinue = completion() - - if shouldContinue { - self?.delayUntil(completion: completion) - } - } - } - - private func delayAndCompleteWithPossibleError(successResponse: T, completionToCall: @escaping (Result) -> Void) { - let delay = TimeInterval.random(in: delayRange) - - DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + delay) { - let shouldResponseBeSuccessful = Int.random(in: 0...100) > self.errorRate - let response: Result = shouldResponseBeSuccessful ? .success(successResponse) : .failure(AFError.explicitlyCancelled) - completionToCall(response) - } - } -} diff --git a/Tree Tracker/Utilities/URLImageLoader.swift b/Tree Tracker/Utilities/URLImageLoader.swift deleted file mode 100644 index 88b3977..0000000 --- a/Tree Tracker/Utilities/URLImageLoader.swift +++ /dev/null @@ -1,83 +0,0 @@ -import Foundation -import class UIKit.UIImage -import struct CoreGraphics.CGSize -import Resolver - -fileprivate extension LogCategory { - static var imageLoader = LogCategory(name: "ImageLoader") -} - -final class URLImageLoader: ImageLoader { - let url: String - - var id: String { - return url - } - - @Injected private var api: Api - - private let thumbnailsImageCache: ImageCaching - private let logger: Logging - - init(url: String, thumbnailsImageCache: ImageCaching = CurrentEnvironment.imageCache, logger: Logging = CurrentEnvironment.logger) { - self.url = url - self.thumbnailsImageCache = thumbnailsImageCache - self.logger = logger - } - - func loadThumbnail(completion: @escaping (UIImage?) -> Void) { - DispatchQueue.global(qos: .userInitiated).async { [weak self] in - guard let self = self, let url = URL(string: self.url) else { - DispatchQueue.main.async { completion(nil) } - return - } - - if let image = self.thumbnailsImageCache.image(for: url) { - DispatchQueue.main.async { - self.logger.log(.imageLoader, "Image from cache for url: \(url)") - completion(image) - } - - return - } - - self.api.loadImage(url: self.url) { [weak self] image in - guard let self = self, let image = image else { - DispatchQueue.main.async { - completion(nil) - } - return - } - - DispatchQueue.global(qos: .userInitiated).async { - self.logger.log(.imageLoader, "Image from the webz for url: \(self.url)") - self.thumbnailsImageCache.add(image: image, for: url) - self.resize(image: image, completion: completion) - } - } - } - } - - private func resize(image: UIImage, completion: @escaping (UIImage?) -> Void) { - let aspectRatio = image.size.width / image.size.height - let newSize = CGSize(width: self.thumbnailSize.width, height: self.thumbnailSize.width / aspectRatio) - let resizedImage = image.resize(to: newSize) - DispatchQueue.main.async { - completion(resizedImage) - } - } - - func loadHighQualityImage(completion: @escaping (UIImage?) -> Void) { - api.loadImage(url: url) { image in - completion(image) - } - } - - static func == (lhs: URLImageLoader, rhs: URLImageLoader) -> Bool { - return lhs.url == rhs.url - } - - func hash(into hasher: inout Hasher) { - hasher.combine(url) - } -} diff --git a/Unit Tests/AirtableSiteServiceTests.swift b/Unit Tests/AirtableSiteServiceTests.swift deleted file mode 100644 index 3f8ba58..0000000 --- a/Unit Tests/AirtableSiteServiceTests.swift +++ /dev/null @@ -1,170 +0,0 @@ -@testable import Tree_Tracker -import Foundation -import Resolver -import XCTest -import Combine - -class AirtableSiteServiceTests: XCTestCase { - - @Injected private var siteService: SiteService - @Injected private var sessionFactory: AlamofireSessionFactory - - private var newSiteName: String = UUID.init().uuidString - private let DEFAULT_EXPECTATION_TIMEOUT = TimeInterval(5) - private var cancellables: Set = [] - private var deleteQueue: [Site] = [] - - override func setUp() { - let expectation = expectation(description: "Sync()") - siteService.sync() { _ in - expectation.fulfill() - } - waitForExpectations(timeout: 5) - } - - override func tearDown() { - // Delete the sites from Airtable otherwise these will accumulate!! - let session = sessionFactory.get() - if deleteQueue.isNotEmpty { - deleteQueue.forEach { site in - var siteUrl = sessionFactory.getSitesUrl() - siteUrl.appendPathComponent(site.id) - let request = session.request(siteUrl, - method: .delete) - - let expectation = expectation(description: "Site deletion") - - request.validate().response { _ in - print("Deleted site \(site.id)") - expectation.fulfill() - } - } - waitForExpectations(timeout: 5) - } - } - - func xtest_sut_available() { - XCTAssertNotNil(siteService) - } - - func xtest_fetchAll() { - let expectation = expectation(description: "Get sites") - siteService.fetchAll() { result in - expectation.fulfill() - do { - let sites = try result.get() - XCTAssertTrue( sites.isNotEmpty ) - XCTAssertTrue( sites.count > 1 ) - } catch { - XCTFail("Error fetching sites: \(error)") - } - } - waitForExpectations(timeout: DEFAULT_EXPECTATION_TIMEOUT) - } - - func xtest_fetchAll_andAdd() { - - var initialSites: [Site] = [] - var newSites: [Site] = [] - - // expect and wait for fetching sites before add - let getInitialExpectation = expectation(description: "Get initial sites list") - - siteService.fetchAll() { result in - getInitialExpectation.fulfill() - do { - initialSites = try result.get() - } catch { - XCTFail("Error fetching sites: \(error)") - } - } - - waitForExpectations(timeout: DEFAULT_EXPECTATION_TIMEOUT) - - // expect and wait for addition of new site - let addSiteExpectation = expectation(description: "Add site") - - siteService.addSite(name: newSiteName) { _ in - addSiteExpectation.fulfill() - } - - waitForExpectations(timeout: DEFAULT_EXPECTATION_TIMEOUT) - - // expect and wait for the results of a subsequent fetch of sites - let getUpdatedExpectation = expectation(description: "Get updated sites list") - - siteService.fetchAll() { result in - getUpdatedExpectation.fulfill() - do { - newSites = try result.get() - } catch { - XCTFail("Error fetching sites: \(error)") - } - } - - waitForExpectations(timeout: DEFAULT_EXPECTATION_TIMEOUT) - - XCTAssertNotNil(newSites) - // new sites should be 1 more than original list - XCTAssertTrue(newSites.count == (initialSites.count + 1)) - // and should contain a site with our new name - let newSite = newSites.first(where: { $0.name == newSiteName }) - XCTAssertNotNil(newSite) - - if newSite != nil { - deleteQueue.append(newSite!) - } - } - - func xtest_addPublishesUpdatedSitesList() { - - var initialSites: [Site] = [] - var newPublishedSites: [Site] = [] - - // expect and wait for fetching sites before add - let getInitialExpectation = expectation(description: "Get initial sites list") - - siteService.fetchAll() { result in - getInitialExpectation.fulfill() - do { - initialSites = try result.get() - } catch { - XCTFail("Error fetching sites: \(error)") - } - } - - waitForExpectations(timeout: DEFAULT_EXPECTATION_TIMEOUT) - - let publisherExpectation = expectation(description: "Add triggers publisher") - - // capture publishing of updated sites (happens later!) - siteService.sitesPublisher.sink() { sites in - if ( sites.count == (initialSites.count + 1) && - sites.contains(where: { self.newSiteName == $0.name })) { - newPublishedSites = sites - // We have fulfilled the expectation that sites list should be increased - // and should contain a site with our new name - publisherExpectation.fulfill() - } - }.store(in: &cancellables) - - // now add the new site, fire and forget style - let addExpectation = expectation(description: "Add site") - siteService.addSite(name: newSiteName, completion: { _ in - addExpectation.fulfill() - }) - - // wait for the add site and publisher expectations (getInitial having already been fulfilled) - // note that this is effectively also an assertion - waitForExpectations(timeout: DEFAULT_EXPECTATION_TIMEOUT) - - // and should also contain a site with our new name - let newSite = newPublishedSites.first(where: { $0.name == newSiteName }) - XCTAssertNotNil(newSite) - - if newSite != nil { - deleteQueue.append(newSite!) - } - } - -} diff --git a/Unit Tests/Info.plist b/Unit Tests/Info.plist index f898d35..2bd7ac5 100644 --- a/Unit Tests/Info.plist +++ b/Unit Tests/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 0.10.2 + 0.10.3 CFBundleVersion 1 diff --git a/Unit Tests/ProtectEarthSiteServiceTests.swift b/Unit Tests/ProtectEarthSiteServiceTests.swift index 0b3b36c..6b7c021 100644 --- a/Unit Tests/ProtectEarthSiteServiceTests.swift +++ b/Unit Tests/ProtectEarthSiteServiceTests.swift @@ -8,8 +8,6 @@ class ProtectEarthSiteServiceTests: XCTestCase { private let DEFAULT_EXPECTATION_TIMEOUT = TimeInterval(5) override func setUpWithError() throws { - // Use protectEarthApi resolver - Resolver.root = Resolver.protectEarthApi siteService = Resolver.resolve(SiteService.self) let expectation = expectation(description: "Sync()") diff --git a/Unit Tests/ProtectEarthSpeciesServiceTests.swift b/Unit Tests/ProtectEarthSpeciesServiceTests.swift index 0ebfaf7..e103b9b 100644 --- a/Unit Tests/ProtectEarthSpeciesServiceTests.swift +++ b/Unit Tests/ProtectEarthSpeciesServiceTests.swift @@ -8,8 +8,6 @@ class ProtectEarthSpeciesServiceTests: XCTestCase { private let DEFAULT_EXPECTATION_TIMEOUT = TimeInterval(5) override func setUpWithError() throws { - // Use protectEarthApi resolver - Resolver.root = Resolver.protectEarthApi speciesService = Resolver.resolve(SpeciesService.self) let expectation = expectation(description: "Sync()") diff --git a/Unit Tests/ProtectEarthSupervisorServiceTests.swift b/Unit Tests/ProtectEarthSupervisorServiceTests.swift index aa9dbe4..ea78c26 100644 --- a/Unit Tests/ProtectEarthSupervisorServiceTests.swift +++ b/Unit Tests/ProtectEarthSupervisorServiceTests.swift @@ -8,8 +8,6 @@ class ProtectEarthSupervisorServiceTests: XCTestCase { private let DEFAULT_EXPECTATION_TIMEOUT = TimeInterval(5) override func setUpWithError() throws { - // Use protectEarthApi resolver - Resolver.root = Resolver.protectEarthApi supervisorService = Resolver.resolve(SupervisorService.self) let expectation = expectation(description: "Sync()") From ad1ab1a39d876264b9cd2985e017cf139a7195d9 Mon Sep 17 00:00:00 2001 From: Allan Lang Date: Mon, 12 Sep 2022 12:53:20 +0100 Subject: [PATCH 3/5] Remove Airtable secrets from pouch --- .pouch.yml | 9 --------- 1 file changed, 9 deletions(-) diff --git a/.pouch.yml b/.pouch.yml index 275cc4b..d3be74c 100644 --- a/.pouch.yml +++ b/.pouch.yml @@ -1,15 +1,6 @@ secrets: -- AIRTABLE_API_KEY -- AIRTABLE_BASE_ID -- AIRTABLE_TREES_TABLE_NAME -- AIRTABLE_SPECIES_TABLE_NAME -- AIRTABLE_SUPERVISORS_TABLE_NAME -- AIRTABLE_SITES_TABLE_NAME - CLOUDINARY_CLOUD_NAME - CLOUDINARY_UPLOAD_PRESET_NAME -- TEST_AIRTABLE_API_KEY -- TEST_AIRTABLE_BASE_ID -- TEST_AIRTABLE_TABLE_NAME_PREFIX - PROTECT_EARTH_API_TOKEN - PROTECT_EARTH_API_BASE_URL - PROTECT_EARTH_ENV_NAME From 297b72a21e54bc5a8b83227f08ec87d4c7b317c0 Mon Sep 17 00:00:00 2001 From: Allan Lang Date: Mon, 12 Sep 2022 13:29:18 +0100 Subject: [PATCH 4/5] Update README, remove redundant scheme --- README.md | 67 +++------------ .../xcschemes/Protect Earth API.xcscheme | 84 ------------------- 2 files changed, 12 insertions(+), 139 deletions(-) delete mode 100644 Tree Tracker.xcodeproj/xcshareddata/xcschemes/Protect Earth API.xcscheme diff --git a/README.md b/README.md index ca05ce4..5192ad7 100644 --- a/README.md +++ b/README.md @@ -1,84 +1,41 @@ # Tree Tracker -App for taking pictures of trees and storing that on a remote server. Mainly used by people who plant trees so they don't have to manually type coordinates with pictures they took and then try to guess the site/species afterwards. +App for cataloguing trees planted and allowing the recorded trees to be uploaded via a custom API to a centralised database. Mainly used by people who plant trees so they don't have to manually type coordinates with pictures they took and then try to guess the site/species afterwards. ## Running the app from Xcode with Mock server 1. Make sure you have downloaded Xcode 13.4+ 2. Open the project in Xcode (you'll notice the dependencies will start to fetch in the background). (In the meantime, Xcode will need to fetch dependencies for the project... 😴) 3. The signing settings for the project are configured for our CICD build pipeline, and will not allow you to build and run the app on your own device. To fix this, simply enable automatic signing in XCode and update the bundle identifier to something unique to you. This will update the .xcodeproj file accordingly. **NOTE** _Changes to signing settings must not be checked in, as these will break the automated builds._ -4. Running the `Tree Tracker` scheme will use the main Airtable base you [configure in your secrets file](#config) and will make inserts to your base tables. Running the `Tree Tracker (Mock server` scheme will use hard-coded mock API responses and will not touch Airtable. +4. Running the `Tree Tracker` scheme will use the API settings you [configure in your secrets file](#config). 5. When running on a device, you'll also need to trust the certificate in _Settings -> General -> Profiles_, otherwise you'll see an error after installing the build and before running it. -## Using your own Airtable/Cloudinary server -Well, this is a bit complicated but still doable. -Sign up for a free [Airtable](https://www.airtable.com) account, as you will need to provide the details of *2* Airtable bases - one -to support the execution of integration tests, and one for the app to use when in normal usage. - -For development purposes, the 2 bases -can actually be the same. If you are doing this, it is recommended to create two sets of tables in the same base, and use a prefix on -the table name. This can then be specified in the `TEST_AIRTABLE_TABLE_NAME_PREFIX` secret (see [later](#config)). - -### Airtable tables -Our current API type expects that you have 4 tables: - -#### Trees Planted -| ID | Notes | Image | Species | Supervisor | Sites | Coordinates | What3Words | CreatedDate | UploadedDate | ImageSignature | -| - | - | - | - | - | - | - | - | - | - | - | -| Auto Number | Long text | Attachment | Link to Species table | Link to Supervisors table | Link to Sites table | Text | Text | Date and time | Date and time | Text | -| number | string | array of attachment objects | array of record IDs (strings) | array of record IDs (strings) | array of record IDs (strings) | string | string | string (ISO 8601 formatted date) | string (ISO 8601 formatted date) | string | - -#### Species -_(notice there is no "ID" field - this is because we use the auto-generated ID through Airtable, the "ID" in the column above is a custom Auto Number field added manually)_ -| Name | -| - | -| Long text | -| Long text | - -#### Supervisors -_(the same structure as above)_ -| Name | -| - | -| Long text | -| Long text | - -#### Sites -_(the same structure as above)_ -| Name | -| - | -| Long text | -| Long text | +## Using your own Cloudinary server ### Cloudinary setup -Because Airtable doesn't support uploading images yet, we have to use an external provider to do so instead. We tried Imgur, but the API is really not user friendly due to its auth requisites. For now, we are using Cloudinary but it might change in the future. +Cloudinary is used as an image storage and manipulation service, to temporarily hold captured images of the trees and allow these to be quickly resized for our needs. 1. Create a free account on [Cloudinary](https://cloudinary.com/users/register/free) (this will give you the needed Cloud name). 2. Now create an [upload preset](https://cloudinary.com/console/settings/upload) (this will give you the Upload Preset name). -3. Keep the keys as you'd need to add them to Secrets.xcconfig later on. +3. Keep the keys as you'd need to add them to Secrets.swift later on. ## Rollbar We use [Rollbar](https://www.rollbar.com) for centralised logging of errors, to help us troubleshoot issues with the app during real world usage. -If you wish, you can sign up for a free Rollbar account, generate your own API token and provide it through `ROLLBAR_AUTH_TOKEN` to see telemetry -in Rollbar during development. This can be useful if you are specifically adding telemetry features, but otherwise is probably more complex than -just looking at the logs in XCode console. +If you wish, you can sign up for a free Rollbar account, generate your own API token and provide it through `ROLLBAR_AUTH_TOKEN` to see telemetry in Rollbar during development. This can be useful if you are specifically adding telemetry features, but otherwise is probably more complex than just looking at the logs in XCode console. If you choose not to setup Rollbar, simply add a dummy value for `ROLLBAR_AUTH_TOKEN` and any Rollbar calls will silently fail. ## Additional project config {#config} -Now, to run the project, we'll need to generate Secrets file. This means you need to run first install [`pouch`](https://github.com/sunshinejr/pouch) (the easiest is using `brew install sunshinejr/formulae/pouch`). Now, you need to have these environment variables available. Have this at the end of the file (bash: most likely in `.bash_profile` or `.bashrc`, zsh: most likely `.zshenv` or `.zshrc`): +Now, to run the project, we'll need to generate the Secrets file. This means you need to run first install [`pouch`](https://github.com/sunshinejr/pouch) (the easiest is using `brew install sunshinejr/formulae/pouch`). Now, you need to have these environment variables available. It would be wise to prepare this file once and keep it somewhere obvious but take care not to check it in to Git. You can simply `source` the file whenever you need to regenerate Secrets. + ``` -export AIRTABLE_API_KEY=yourKey123 -export AIRTABLE_BASE_ID=appNiceTree -export AIRTABLE_TREES_TABLE_NAME="Trees Planted" -export AIRTABLE_SPECIES_TABLE_NAME=Species -export AIRTABLE_SUPERVISORS_TABLE_NAME=Supervisors -export AIRTABLE_SITES_TABLE_NAME=Sites export CLOUDINARY_CLOUD_NAME=qqq2ek4mq export CLOUDINARY_UPLOAD_PRESET_NAME=iadfadff -export TEST_AIRTABLE_API_KEY=yourTestKey123 -export TEST_AIRTABLE_BASE_ID=appNiceTreeTest -export TEST_AIRTABLE_TABLE_NAME_PREFIX=test_ +export PROTECT_EARTH_API_TOKEN="n|xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +export PROTECT_EARTH_API_BASE_URL="api.endpoint.com" +export PROTECT_EARTH_ENV_NAME=Development export ROLLBAR_AUTH_TOKEN=yourRollbarToken ``` + In the root folder, run `pouch`, which should generate a file at `./TreeTracker/Secrets.swift`. With all that, you can switch the scheme to `Tree Tracker` and it _should_ run just fine. diff --git a/Tree Tracker.xcodeproj/xcshareddata/xcschemes/Protect Earth API.xcscheme b/Tree Tracker.xcodeproj/xcshareddata/xcschemes/Protect Earth API.xcscheme deleted file mode 100644 index 24dc403..0000000 --- a/Tree Tracker.xcodeproj/xcshareddata/xcschemes/Protect Earth API.xcscheme +++ /dev/null @@ -1,84 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - From 4a1972000695039f746354e1f811b803140c9bd9 Mon Sep 17 00:00:00 2001 From: Allan Lang Date: Mon, 12 Sep 2022 13:42:23 +0100 Subject: [PATCH 5/5] Uupdate changelog --- CHANGELOG.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94bd112..cff63dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ # Tree Tracker -## Next +## 0.10.3 +- Airtable integration is entirely replaced with Protect Earth custom API backend +- Removal of all redundant code +- Adoption of Resolver framework for dependency injection throughout +- Integration of Rollbar for error tracking and general telemetry so we can see how the app is behaving in the wild ## 0.7.0 - Updated the UI