From 20723dab571fd0c155197093190d293886f876bd Mon Sep 17 00:00:00 2001
From: Philippe Auriach
Date: Tue, 5 Mar 2019 14:50:42 +0100
Subject: [PATCH] offline products consultation. Fix #35
---
Cartfile | 3 +-
Cartfile.resolved | 2 +
OpenFoodFacts.xcodeproj/project.pbxproj | 18 ++
Sources/AppDelegate.swift | 2 +-
.../Localization/fr.lproj/Localizable.strings | 1 +
Sources/Models/DataManager.swift | 6 +
Sources/Models/PersistenceManager.swift | 15 +-
.../Models/Realm/RealmOfflineProduct.swift | 25 +++
Sources/Network/CSVStreamReader.swift | 157 ++++++++++++++++++
Sources/Network/OfflineProductsService.swift | 130 +++++++++++++++
.../Search/ScannerResultViewController.swift | 9 +
.../Search/ScannerViewController.swift | 58 +++++--
.../ViewControllers/RootViewController.swift | 4 +
.../Scanner/ScanProductSummaryView.swift | 37 +++++
Sources/en.lproj/Localizable.strings | 1 +
15 files changed, 446 insertions(+), 22 deletions(-)
create mode 100644 Sources/Models/Realm/RealmOfflineProduct.swift
create mode 100644 Sources/Network/CSVStreamReader.swift
create mode 100644 Sources/Network/OfflineProductsService.swift
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";