diff --git a/Cartfile b/Cartfile index 59cdf5ab042..c79b2f01a50 100644 --- a/Cartfile +++ b/Cartfile @@ -13,4 +13,5 @@ github "PiXeL16/IBLocalizable" "Swift4" github "realm/realm-cocoa" github "SnapKit/SnapKit" github "scenee/FloatingPanel" "v1.3.5" -github "TimOliver/TOCropViewController" \ No newline at end of file +github "TimOliver/TOCropViewController" +github "marmelroy/Zip" "1.1.0" diff --git a/Cartfile.resolved b/Cartfile.resolved index 267b2ffba1d..c3d60331f1f 100644 --- a/Cartfile.resolved +++ b/Cartfile.resolved @@ -2,6 +2,7 @@ github "Alamofire/Alamofire" "4.8.1" github "AliSoftware/OHHTTPStubs" "4dc6f36375f78c0b3cfe58d90bb8a4e21df5196e" github "Daltron/NotificationBanner" "2.0.1" github "DaveWoodCom/XCGLogger" "6.1.0" +github "Flinesoft/HandySwift" "2.8.0" github "PiXeL16/IBLocalizable" "a0d7a8fab4cec66b592ac83f9efbc6f30bd21a9b" github "Quick/Nimble" "v7.3.1" github "Quick/Quick" "v1.3.2" @@ -13,6 +14,7 @@ github "cbpowell/MarqueeLabel" "3.2.0" github "hackiftekhar/IQKeyboardManager" "v5.0.6" github "httpswift/swifter" "294dc8eaa7aed12f8695f43a5749b5c8f0f175b7" github "kishikawakatsumi/KeychainAccess" "v3.1.2" +github "marmelroy/Zip" "1.1.0" github "onevcat/Kingfisher" "5.1.0" github "realm/realm-cocoa" "v3.13.1" github "scenee/FloatingPanel" "v1.3.5" diff --git a/OpenFoodFacts.xcodeproj/project.pbxproj b/OpenFoodFacts.xcodeproj/project.pbxproj index 0945c848d7f..d985da66ebe 100644 --- a/OpenFoodFacts.xcodeproj/project.pbxproj +++ b/OpenFoodFacts.xcodeproj/project.pbxproj @@ -22,6 +22,10 @@ 30270C322226EAD000E6973D /* SummaryFooterCellController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 30270C312226EAD000E6973D /* SummaryFooterCellController.xib */; }; 30270C342226F4A000E6973D /* operators.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30270C332226F4A000E6973D /* operators.swift */; }; 30270C37222743D200E6973D /* EnvironmentImpactTableFormTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30270C36222743D200E6973D /* EnvironmentImpactTableFormTableViewController.swift */; }; + 3030EB84222E8290005A6169 /* OfflineProductsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3030EB83222E8290005A6169 /* OfflineProductsService.swift */; }; + 3030EB8A222E9202005A6169 /* RealmOfflineProduct.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3030EB89222E9202005A6169 /* RealmOfflineProduct.swift */; }; + 3030EB8B222E992B005A6169 /* Zip.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3030EB85222E8A08005A6169 /* Zip.framework */; }; + 3030EB8D222E9954005A6169 /* CSVStreamReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3030EB8C222E9954005A6169 /* CSVStreamReader.swift */; }; 303C279D2201AB6B00159961 /* ScanProductSummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 303C279C2201AB6B00159961 /* ScanProductSummaryView.swift */; }; 303C279F2201AB7B00159961 /* ScanProductSummaryView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 303C279E2201AB7B00159961 /* ScanProductSummaryView.xib */; }; 303C27A12201ABC300159961 /* ManualBarcodeInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 303C27A02201ABC300159961 /* ManualBarcodeInputView.swift */; }; @@ -307,6 +311,10 @@ 30270C312226EAD000E6973D /* SummaryFooterCellController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = SummaryFooterCellController.xib; sourceTree = ""; }; 30270C332226F4A000E6973D /* operators.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = operators.swift; sourceTree = ""; }; 30270C36222743D200E6973D /* EnvironmentImpactTableFormTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentImpactTableFormTableViewController.swift; sourceTree = ""; }; + 3030EB83222E8290005A6169 /* OfflineProductsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineProductsService.swift; sourceTree = ""; }; + 3030EB85222E8A08005A6169 /* Zip.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Zip.framework; path = Carthage/Build/iOS/Zip.framework; sourceTree = ""; }; + 3030EB89222E9202005A6169 /* RealmOfflineProduct.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RealmOfflineProduct.swift; sourceTree = ""; }; + 3030EB8C222E9954005A6169 /* CSVStreamReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CSVStreamReader.swift; sourceTree = ""; }; 303C279C2201AB6B00159961 /* ScanProductSummaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanProductSummaryView.swift; sourceTree = ""; }; 303C279E2201AB7B00159961 /* ScanProductSummaryView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ScanProductSummaryView.xib; sourceTree = ""; }; 303C27A02201ABC300159961 /* ManualBarcodeInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualBarcodeInputView.swift; sourceTree = ""; }; @@ -919,6 +927,7 @@ 9526F8FB1FE1C5230008E1CC /* Crashlytics.framework in Frameworks */, 307BBEB921FA16B100E2DF9D /* FloatingPanel.framework in Frameworks */, 30DB17F522122E4A0010EE6F /* TOCropViewController.framework in Frameworks */, + 3030EB8B222E992B005A6169 /* Zip.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1130,6 +1139,8 @@ children = ( 957094AE1EA124E800268236 /* ProductService.swift */, 3000855D2207A3A500DC3896 /* TaxonomiesService.swift */, + 3030EB83222E8290005A6169 /* OfflineProductsService.swift */, + 3030EB8C222E9954005A6169 /* CSVStreamReader.swift */, ); path = Network; sourceTree = ""; @@ -1176,6 +1187,7 @@ children = ( 955BAFFD1FF828540046F419 /* RealmPendingUploadItem.swift */, 302525EB222307A200C2C830 /* RealmUserPreferences.swift */, + 3030EB89222E9202005A6169 /* RealmOfflineProduct.swift */, ); path = Realm; sourceTree = ""; @@ -1637,6 +1649,7 @@ 95C265231E96D5C2004212EC /* Frameworks */ = { isa = PBXGroup; children = ( + 3030EB85222E8A08005A6169 /* Zip.framework */, 30DB17F422122E4A0010EE6F /* TOCropViewController.framework */, 307BBEB821FA16B100E2DF9D /* FloatingPanel.framework */, 9526F8F91FE1C5230008E1CC /* Crashlytics.framework */, @@ -2232,6 +2245,7 @@ "$(SRCROOT)/Carthage/Build/iOS/RealmSwift.framework", "$(SRCROOT)/Carthage/Build/iOS/FloatingPanel.framework", "$(SRCROOT)/Carthage/Build/iOS/TOCropViewController.framework", + "$(SRCROOT)/Carthage/Build/iOS/Zip.framework", ); name = Carthage; outputPaths = ( @@ -2253,6 +2267,7 @@ "$(BUILT_PRODUCTS_DIR)/$(FRAMEWORKS_FOLDER_PATH)/RealmSwift.framework", "$(BUILT_PRODUCTS_DIR)/$(FRAMEWORKS_FOLDER_PATH)/FloatingPanel.framework", "$(BUILT_PRODUCTS_DIR)/$(FRAMEWORKS_FOLDER_PATH)/TOCropViewController.framework", + "$(BUILT_PRODUCTS_DIR)/$(FRAMEWORKS_FOLDER_PATH)/Zip.framework", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; @@ -2344,6 +2359,7 @@ 958F33441F361A9D005269C5 /* IngredientsHeaderCellController.swift in Sources */, 956FF9551F1FF0CA0069D678 /* ProductTableViewCell.swift in Sources */, 95781BC31EC3A898003E3256 /* DoubleTransform.swift in Sources */, + 3030EB84222E8290005A6169 /* OfflineProductsService.swift in Sources */, 9587DF731F84056F0069F0A6 /* PictureViewModel.swift in Sources */, 95781BBC1EC3A898003E3256 /* OFFReadAPIKeysJSON.swift in Sources */, 95A566141FD6037800C997C8 /* LocalizableTextField.swift in Sources */, @@ -2413,9 +2429,11 @@ 957CBEAC1F323B1F00A1B398 /* FormTableViewController.swift in Sources */, 957CBEC41F33B36400A1B398 /* SummaryFormTableViewController.swift in Sources */, 74C59E8B203FB9E2006C456F /* SharingManager.swift in Sources */, + 3030EB8A222E9202005A6169 /* RealmOfflineProduct.swift in Sources */, 95D1D29C1FFC33B600595EA1 /* ShortcutParser.swift in Sources */, 9526F8F51FDF2A8B0008E1CC /* DataManagerClient.swift in Sources */, 958F335B1F37809A005269C5 /* PictureCallToActionView.swift in Sources */, + 3030EB8D222E9954005A6169 /* CSVStreamReader.swift in Sources */, 95D060DD1F609DF70052012D /* LoadingCell.swift in Sources */, 95DB3BEA1FDD97D800E83B76 /* HistoryTableViewController.swift in Sources */, 958F334D1F377831005269C5 /* NutritionTableHeaderCellController.swift in Sources */, diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index b23e36a48df..2f2e0d3f566 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -62,7 +62,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { private func configureRealm() { let config = Realm.Configuration( - schemaVersion: 21 + schemaVersion: 23 ) Realm.Configuration.defaultConfiguration = config diff --git a/Sources/Localization/fr.lproj/Localizable.strings b/Sources/Localization/fr.lproj/Localizable.strings index 8687b0ada67..386d25a6c5b 100644 --- a/Sources/Localization/fr.lproj/Localizable.strings +++ b/Sources/Localization/fr.lproj/Localizable.strings @@ -163,6 +163,7 @@ "product-detail.ingredients.allergens-list" = "Substances ou produits provoquant des allergies ou intolérances"; "product-detail.ingredients.allergens-alert.title" = "Contient des allergènes !"; "product-detail.ingredients.allergens-list.missing-infos" = "Ce produit n'est pas complet. En conséquence, nous n'avons pas pu évaluer la présence d'allergènes."; +"product-detail.ingredients.allergens-list.offline-product" = "Ce produit provient de la base de donnée \"hors-ligne\". En conséquence, nous n'avons pas pu évaluer la présence d'allergènes."; "product-detail.ingredients.traces-list" = "Traces éventuelles"; "product-detail.ingredients.additives-list" = "Additifs"; "product-detail.ingredients.palm-oil-ingredients" = "Ingrédients issus de l'huile de palme"; diff --git a/Sources/Models/DataManager.swift b/Sources/Models/DataManager.swift index cbca4aa09da..1528de55919 100644 --- a/Sources/Models/DataManager.swift +++ b/Sources/Models/DataManager.swift @@ -19,6 +19,8 @@ protocol DataManagerProtocol { isSummary: Bool, onSuccess: @escaping (Product?) -> Void, onError: @escaping (Error) -> Void) + func getOfflineProduct(forCode: String) -> RealmOfflineProduct? + // User func logIn(username: String, password: String, onSuccess: @escaping () -> Void, onError: @escaping (Error) -> Void) @@ -94,6 +96,10 @@ class DataManager: DataManagerProtocol { }) } + func getOfflineProduct(forCode: String) -> RealmOfflineProduct? { + return persistenceManager.getOfflineProduct(forCode: forCode) + } + // MARK: - User func logIn(username: String, password: String, onSuccess: @escaping () -> Void, onError: @escaping (Error) -> Void) { diff --git a/Sources/Models/PersistenceManager.swift b/Sources/Models/PersistenceManager.swift index dd9e28d2672..7ab3a438266 100644 --- a/Sources/Models/PersistenceManager.swift +++ b/Sources/Models/PersistenceManager.swift @@ -34,6 +34,10 @@ protocol PersistenceManagerProtocol { func save(additives: [Additive]) func additive(forCode: String) -> Additive? + // Offline + func save(offlineProducts: [RealmOfflineProduct]) + func getOfflineProduct(forCode: String) -> RealmOfflineProduct? + // allergies settings func addAllergy(toAllergen: Allergen) func removeAllergy(toAllergen: Allergen) @@ -52,14 +56,13 @@ class PersistenceManager: PersistenceManagerProtocol { fileprivate func saveOrUpdate(objects: [Object]) { let realm = getRealm() - print(Realm.Configuration.defaultConfiguration.fileURL!) do { try realm.write { realm.add(objects, update: true) } } catch let error as NSError { - log.error(error) + log.error("ERROR SAVING INTO REALM \(error)") Crashlytics.sharedInstance().recordError(error) } } @@ -178,6 +181,14 @@ class PersistenceManager: PersistenceManagerProtocol { return getRealm().object(ofType: Additive.self, forPrimaryKey: code) } + func save(offlineProducts: [RealmOfflineProduct]) { + saveOrUpdate(objects: offlineProducts) + } + + func getOfflineProduct(forCode: String) -> RealmOfflineProduct? { + return getRealm().object(ofType: RealmOfflineProduct.self, forPrimaryKey: forCode) + } + // MARK: User Preferences fileprivate func getRealmUserPreferences() -> RealmUserPreferences { if let prefs = getRealm().objects(RealmUserPreferences.self).first { diff --git a/Sources/Models/Realm/RealmOfflineProduct.swift b/Sources/Models/Realm/RealmOfflineProduct.swift new file mode 100644 index 00000000000..171faa392df --- /dev/null +++ b/Sources/Models/Realm/RealmOfflineProduct.swift @@ -0,0 +1,25 @@ +// +// RealmOfflineProduct.swift +// OpenFoodFacts +// +// Created by Philippe Auriach on 05/03/2019. +// Copyright © 2019 Andrés Pizá Bückmann. All rights reserved. +// + +import Foundation +import RealmSwift + +class RealmOfflineProduct: Object { + + @objc dynamic var barcode = "" + @objc dynamic var name: String? + @objc dynamic var quantity: String? + @objc dynamic var brands: String? + @objc dynamic var nutritionGrade: String? + @objc dynamic var novaGroup: String? + + + override static func primaryKey() -> String? { + return "barcode" + } +} diff --git a/Sources/Network/CSVStreamReader.swift b/Sources/Network/CSVStreamReader.swift new file mode 100644 index 00000000000..01407518e61 --- /dev/null +++ b/Sources/Network/CSVStreamReader.swift @@ -0,0 +1,157 @@ +// +// OfflineService.swift +// OpenFoodFacts +// +// Created by Philippe Auriach on 05/03/2019. +// + +import Foundation + +class CSVStreamReader { + let encoding: String.Encoding + let chunkSize: Int + let fileHandle: FileHandle + var buffer: Data + let delimPattern: Data + let csvDelimiter: String + var colHeaders: [String]? + + init?(url: URL, + delimiter: String = "\n", + encoding: String.Encoding = .utf8, + chunkSize: Int = 4096, + csvDelimiter: String = ",", + colHeaders: [String]? = nil) { + + if FileManager.default.fileExists(atPath: url.path) == false { + log.error("[CSVStreamReader] File do not exist, impossible to use CSVStreamReader") + return nil + } + + do { + let fileHandle = try FileHandle(forReadingFrom: url) + self.fileHandle = fileHandle + } catch let error as NSError { + log.error("[CSVStreamReader] File handle no created ? \(error)") + return nil + } + + self.chunkSize = chunkSize + self.encoding = encoding + buffer = Data(capacity: chunkSize) + delimPattern = delimiter.data(using: .utf8)! + self.csvDelimiter = csvDelimiter + + self.colHeaders = colHeaders + } + + deinit { + fileHandle.closeFile() + } + + func nextLine() -> String? { + repeat { + if let range = buffer.range(of: delimPattern, options: [], in: buffer.startIndex ..< buffer.endIndex) { + let subData = buffer.subdata(in: buffer.startIndex ..< range.lowerBound) + let line = String(data: subData, encoding: encoding) + buffer.replaceSubrange(buffer.startIndex ..< range.upperBound, with: []) + return line + } else { + let tempData = fileHandle.readData(ofLength: chunkSize) + if tempData.isEmpty { + return (buffer.isEmpty == false) ? String(data: buffer, encoding: encoding) : nil + } + buffer.append(tempData) + } + } while true + } + + func nextCSVLine() -> [String]? { + guard let line = nextLine() else { + return nil + } + + return line.components(separatedBy: csvDelimiter) + .map { $0.trimmingCharacters(in: CharacterSet.whitespaces) } + } + + func streamCSV(onLineItem: @escaping ([String: String]) -> Void) { + if colHeaders == nil { + colHeaders = nextCSVLine() + } + + guard let colHeaders = colHeaders else { + log.error("[CSVStreamReader] Tried to stream in csv mode without headers ??") + return + } + + var continueRepeat = true + repeat { + autoreleasepool { + var lineItem = [String: String]() + guard let line = nextCSVLine() else { + continueRepeat = false + return + } + for (index, col) in colHeaders.enumerated() where line.count > index { + lineItem[col] = line[index] + } + if lineItem.isEmpty == false { + onLineItem(lineItem) + } + } + } while continueRepeat + } +} + +class TypedCSVStreamReader: CSVStreamReader { + + func batchStream(batchSize: Int, parse: @escaping ([String]) -> T?, treatBatch: @escaping ([T]) -> Void) { + var batch = [T]() + + var continueRepeat = true + repeat { + autoreleasepool { + guard let line = nextLine() else { + continueRepeat = false + return + } + let datas = line.split(separator: "\t").map { $0.trimmingCharacters(in: .whitespaces )} + if datas.isEmpty == false, let parsed = parse(datas) { + batch.append(parsed) + } + + if batch.count >= batchSize { + treatBatch(batch) + batch.removeAll() + } + } + } while continueRepeat + + if batch.isEmpty == false { + treatBatch(batch) + batch.removeAll() + } + } + + func batchStreamCSV(batchSize: Int, parse: @escaping ([String: String]) -> T?, treatBatch: @escaping ([T]) -> Void) { + var batch = [T]() + + streamCSV { (lineItem: [String: String]) in + if let item = parse(lineItem) { + batch.append(item) + } + + if batch.count >= batchSize { + treatBatch(batch) + batch.removeAll() + } + } + + if batch.isEmpty == false { + treatBatch(batch) + batch.removeAll() + } + } + +} diff --git a/Sources/Network/OfflineProductsService.swift b/Sources/Network/OfflineProductsService.swift new file mode 100644 index 00000000000..b8da7758c5c --- /dev/null +++ b/Sources/Network/OfflineProductsService.swift @@ -0,0 +1,130 @@ +// +// OfflineService.swift +// OpenFoodFacts +// +// Created by Philippe Auriach on 05/03/2019. +// Copyright © 2019 Andrés Pizá Bückmann. All rights reserved. +// + +import Zip +import UIKit +import Alamofire +import Crashlytics +import AlamofireObjectMapper + +protocol OfflineProductsApi { + func refreshOfflineProductsFromServerIfNeeded(force: Bool) +} + +class OfflineProductsService: OfflineProductsApi { + var persistenceManager: PersistenceManagerProtocol! + + // swiftlint:disable identifier_name + + /// increment last number each time you want to force a refresh. Useful if you add a new refresh method or a new field + static fileprivate let USER_DEFAULT_LAST_OFFLINE_PRODUCTS_DOWNLOAD = "USER_DEFAULT_LAST_OFFLINE_PRODUCTS_DOWNLOAD__10" + static fileprivate let LAST_DOWNLOAD_DELAY: Double = 60 * 60 * 24 * 31 // 1 month + + // swiftlint:enable identifier_name + + fileprivate static func deleteFile(atURL: URL) { + let fileManager = FileManager.default + + do { + try fileManager.removeItem(at: atURL) + } catch let error { + log.error("[Offline_products] file not deleted at \(atURL) because of \(error)") + } + } + + // swiftlint:disable:next function_body_length + fileprivate func downloadFile(completion: @escaping (Bool) -> Void) { + let url = URL(string: "https://world.openfoodfacts.org/data/offline/en.openfoodfacts.org.products.small.csv.zip")! + + let destination = DownloadRequest.suggestedDownloadDestination(for: .documentDirectory) + + Alamofire.download(url, to: destination) + .downloadProgress(closure: { (progress) in + //progress closure + log.debug("[Offline_products] download zip progress = \(progress.fractionCompleted)") + }).response(completionHandler: { (result: DefaultDownloadResponse) in + + guard let localFilePath = result.destinationURL else { + log.error("[Offline_products] no downloaded zip destination file ?") + completion(false) + return + } + + DispatchQueue.global(qos: .userInitiated).async { + var success = true + do { + let unzipedFolderPath = try Zip.quickUnzipFile(localFilePath) + + let contentFileUrls = try FileManager.default.contentsOfDirectory(at: unzipedFolderPath, includingPropertiesForKeys: nil) + + if let firstFileURL = contentFileUrls.first { + if let streamReader = TypedCSVStreamReader(url: firstFileURL, csvDelimiter: "\t") { + var totalCount = 0 + + streamReader.batchStreamCSV(batchSize: 20000, parse: { (raw: [String : String]) -> RealmOfflineProduct? in + guard let barcode = raw["code"] else { return nil } + + let product = RealmOfflineProduct() + product.barcode = barcode + product.name = raw["product_name"] + product.quantity = raw["quantity"] + product.brands = raw["brands"] + product.nutritionGrade = raw["nutrition_grade_fr"] + product.novaGroup = raw["nova_group"] + + return product + }, treatBatch: { (products: [RealmOfflineProduct]) in + self.persistenceManager.save(offlineProducts: products) + totalCount += products.count + log.debug("[Offline_products] Treated \(products.count) products") + }) + + log.info("[Offline_products] We just saved \(totalCount) products for an offline save !") + success = totalCount > 0 + } + } else { + success = false + } + + OfflineProductsService.deleteFile(atURL: unzipedFolderPath) + } catch let error { + log.error("[Offline_products] Error listing unzipped files! \(error)") + Crashlytics.sharedInstance().recordError(error) + success = false + } + OfflineProductsService.deleteFile(atURL: localFilePath) + completion(success) + } + }) + } + + func refreshOfflineProductsFromServerIfNeeded(force: Bool = false) { + let lastDownload = UserDefaults.standard.double(forKey: OfflineProductsService.USER_DEFAULT_LAST_OFFLINE_PRODUCTS_DOWNLOAD) + + let shouldDownload = lastDownload == 0 || (Date().timeIntervalSince1970 - OfflineProductsService.LAST_DOWNLOAD_DELAY) > lastDownload + + if shouldDownload { + + let backgroundTaskId = UIApplication.shared.beginBackgroundTask(expirationHandler: nil) + + let startTime = CFAbsoluteTimeGetCurrent() + downloadFile { (success) in + if success { + UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: OfflineProductsService.USER_DEFAULT_LAST_OFFLINE_PRODUCTS_DOWNLOAD) + } + + let timeElapsed = CFAbsoluteTimeGetCurrent() - startTime + log.info("[Offline_products] sync took \(timeElapsed) seconds") + + UIApplication.shared.endBackgroundTask(backgroundTaskId) + } + } else { + log.debug("Do not download offline products, we already have them !") + } + } +} diff --git a/Sources/ViewControllers/Products/Search/ScannerResultViewController.swift b/Sources/ViewControllers/Products/Search/ScannerResultViewController.swift index 7655ed1c43d..e5a302a5c15 100644 --- a/Sources/ViewControllers/Products/Search/ScannerResultViewController.swift +++ b/Sources/ViewControllers/Products/Search/ScannerResultViewController.swift @@ -11,6 +11,7 @@ import UIKit enum ScannerResultStatusEnum { case waitingForScan case loading(barcode: String) + case hasOfflineData(product: RealmOfflineProduct) case hasSummary(product: Product) case hasProduct(product: Product, dataManager: DataManagerProtocol) case manualBarcode @@ -56,6 +57,9 @@ class ScannerResultViewController: UIViewController { statusIndicatorLabel.text = barcode + "\n" + "product-scanner.search.status".localized statusIndicatorLabel.isHidden = false + case .hasOfflineData(let product): + updateSummaryVisibility(forProduct: product) + case .hasSummary(let product): updateSummaryVisibility(forProduct: product) @@ -69,6 +73,11 @@ class ScannerResultViewController: UIViewController { } } + fileprivate func updateSummaryVisibility(forProduct product: RealmOfflineProduct) { + topSummaryView.fillIn(product: product) + topSummaryView.isHidden = false + } + fileprivate func updateSummaryVisibility(forProduct product: Product) { topSummaryView.fillIn(product: product) topSummaryView.isHidden = false diff --git a/Sources/ViewControllers/Products/Search/ScannerViewController.swift b/Sources/ViewControllers/Products/Search/ScannerViewController.swift index f37026540ed..418d2bf7a54 100644 --- a/Sources/ViewControllers/Products/Search/ScannerViewController.swift +++ b/Sources/ViewControllers/Products/Search/ScannerViewController.swift @@ -70,7 +70,6 @@ class ScannerViewController: UIViewController, DataManagerClient { configureFlashView() configureTapToFocus() - floatingLabel.text = "⚠️ " + "product-detail.ingredients.allergens-list.missing-infos".localized floatingLabel.textAlignment = .center floatingLabel.numberOfLines = 0 floatingLabel.textColor = .white @@ -307,28 +306,43 @@ extension ScannerViewController: AVCaptureMetadataOutputObjectsDelegate { scannerFloatingPanelLayout.canShowDetails = false DispatchQueue.main.async { self.floatingPanelController.move(to: .tip, animated: true) - self.scannerResultController.status = .loading(barcode: barcode) - } - dataManager.getProduct(byBarcode: barcode, isScanning: true, isSummary: isSummary, onSuccess: { [weak self] response in - self?.handleGetProductSuccess(barcode, response, isSummary: isSummary, createIfNeeded: createIfNeeded) + var hasOfflineSave = false - if response != nil, isSummary { - self?.getProduct(barcode: barcode, isSummary: false) + if isSummary { + if let offlineProduct = self.dataManager.getOfflineProduct(forCode: barcode) { + self.scannerResultController.status = .hasOfflineData(product: offlineProduct) + self.showAllergensFloatingLabelIfNeeded() + hasOfflineSave = true + } } - }, onError: { [weak self] error in - if isOffline(errorCode: (error as NSError).code) { - // Assume product does not exist and store locally for later upload - self?.handleGetProductSuccess(barcode, nil, isSummary: isSummary, createIfNeeded: createIfNeeded) - } else { - DispatchQueue.main.async { - StatusBarNotificationBanner(title: "product-scanner.barcode.error".localized, style: .danger).show() - self?.scannerResultController.status = .waitingForScan - } - self?.lastCodeScanned = nil + if isSummary && !hasOfflineSave { + self.scannerResultController.status = .loading(barcode: barcode) } - }) + + self.dataManager.getProduct(byBarcode: barcode, isScanning: true, isSummary: isSummary, onSuccess: { [weak self] response in + self?.handleGetProductSuccess(barcode, response, isSummary: isSummary, createIfNeeded: createIfNeeded) + + if response != nil, isSummary { + self?.getProduct(barcode: barcode, isSummary: false) + } + + }, onError: { [weak self] error in + if isOffline(errorCode: (error as NSError).code) { + if hasOfflineSave == false { + // Assume product does not exist and store locally for later upload + self?.handleGetProductSuccess(barcode, nil, isSummary: isSummary, createIfNeeded: createIfNeeded) + } + } else { + DispatchQueue.main.async { + StatusBarNotificationBanner(title: "product-scanner.barcode.error".localized, style: .danger).show() + self?.scannerResultController.status = .waitingForScan + } + self?.lastCodeScanned = nil + } + }) + } } fileprivate func showAllergenAlertIfNeeded(forProduct product: Product) { @@ -382,8 +396,16 @@ extension ScannerViewController: AVCaptureMetadataOutputObjectsDelegate { } fileprivate func showAllergensFloatingLabelIfNeeded() { + if dataManager.listAllergies().isEmpty { + self.floatingLabelContainer.isHidden = true + return + } switch scannerResultController.status { + case .hasOfflineData: + self.floatingLabel.text = "⚠️ " + "product-detail.ingredients.allergens-list.offline-product".localized + self.floatingLabelContainer.isHidden = false case .hasProduct(let product, _): + self.floatingLabel.text = "⚠️ " + "product-detail.ingredients.allergens-list.missing-infos".localized if product.states?.contains("en:ingredients-to-be-completed") == true { self.floatingLabelContainer.isHidden = self.floatingPanelController.position != .tip } else { diff --git a/Sources/ViewControllers/RootViewController.swift b/Sources/ViewControllers/RootViewController.swift index e2a9d496e45..01f367ff71a 100644 --- a/Sources/ViewControllers/RootViewController.swift +++ b/Sources/ViewControllers/RootViewController.swift @@ -34,6 +34,10 @@ class RootViewController: UIViewController { taxonomiesApi.persistenceManager = persistenceManager taxonomiesApi.refreshTaxonomiesFromServerIfNeeded() + let offlineProductsService = OfflineProductsService() + offlineProductsService.persistenceManager = persistenceManager + offlineProductsService.refreshOfflineProductsFromServerIfNeeded() + let dataManager = DataManager() dataManager.productApi = productApi dataManager.taxonomiesApi = taxonomiesApi diff --git a/Sources/Views/Products/Search/Scanner/ScanProductSummaryView.swift b/Sources/Views/Products/Search/Scanner/ScanProductSummaryView.swift index 20aaa7742eb..57c28956bb1 100644 --- a/Sources/Views/Products/Search/Scanner/ScanProductSummaryView.swift +++ b/Sources/Views/Products/Search/Scanner/ScanProductSummaryView.swift @@ -33,6 +33,43 @@ class ScanProductSummaryView: UIView { contentView.autoresizingMask = [.flexibleWidth, .flexibleHeight] } + func fillIn(product: RealmOfflineProduct) { + productImageView.isHidden = true + environmentImpactImageView.isHidden = true + + titleLabel.text = product.name + + brandsLabel.text = nil + if let brands = product.brands, !brands.isEmpty { + brandsLabel.text = brands + brandsLabel.isHidden = false + } else { + brandsLabel.isHidden = true + } + + if let quantity = product.quantity, !quantity.isEmpty { + quantityLabel.text = quantity + quantityLabel.isHidden = false + } else { + quantityLabel.isHidden = true + } + + if let nutriscoreValue = product.nutritionGrade, let score = NutriScoreView.Score(rawValue: nutriscoreValue) { + nutriScoreView.currentScore = score + nutriScoreView.isHidden = false + } else { + nutriScoreView.isHidden = true + } + + if let novaGroupValue = product.novaGroup, + let novaGroup = NovaGroupView.NovaGroup(rawValue: novaGroupValue) { + novaGroupView.novaGroup = novaGroup + novaGroupView.isHidden = false + } else { + novaGroupView.isHidden = true + } + } + func fillIn(product: Product) { titleLabel.text = product.name diff --git a/Sources/en.lproj/Localizable.strings b/Sources/en.lproj/Localizable.strings index 9cf08c6058e..62c67be7bde 100644 --- a/Sources/en.lproj/Localizable.strings +++ b/Sources/en.lproj/Localizable.strings @@ -163,6 +163,7 @@ "product-detail.ingredients.allergens-list" = "Substances or products causing allergies or intolerances"; "product-detail.ingredients.allergens-alert.title" = "Contains allergens!"; "product-detail.ingredients.allergens-list.missing-infos" = "This product doesn't have an ingredient list yet, and as a result, allergen detection could not be completed."; +"product-detail.ingredients.allergens-list.offline-product" = "This product has been loaded from the offline database, and as a result, allergen detection could not be completed."; "product-detail.ingredients.traces-list" = "Traces"; "product-detail.ingredients.additives-list" = "Additives"; "product-detail.ingredients.palm-oil-ingredients" = "Ingredients from palm oil";