diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/OpenFoodFactsSDK.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/OpenFoodFactsSDK.xcscheme new file mode 100644 index 0000000..c0ab5c1 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/OpenFoodFactsSDK.xcscheme @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Package.resolved b/Package.resolved index 85197c0..91ff31f 100644 --- a/Package.resolved +++ b/Package.resolved @@ -3,10 +3,10 @@ { "identity" : "barcodeview", "kind" : "remoteSourceControl", - "location" : "https://github.com/nrivard/BarcodeView.git", + "location" : "https://github.com/hrabkin/BarcodeView.git", "state" : { - "revision" : "ba08384079911583d0fe1d684141eefdef77a777", - "version" : "0.1.4" + "branch" : "master", + "revision" : "1c18b3236db7ac03146981d26a8258b5e62117c8" } }, { diff --git a/README.md b/README.md index 3cf7d5d..256defc 100644 --- a/README.md +++ b/README.md @@ -115,3 +115,7 @@ You can check the terms of use here : [Terms of use](https://world.openfoodfacts If you use this SDK, feel free to open a PR to add your application in this list. ## Authors +This project is sponsored by [FoodIntake](https://foodintake.space) + +![Foodintake-AI-Calorie-Pal-Reddit](https://github.com/hrabkin/openfoodfacts-swift/assets/2230377/60a84e1e-2d16-42a2-9694-4d673cd67b95) + diff --git a/Sources/Extensions/Extensions.swift b/Sources/Extensions/Extensions.swift index 7d05773..416478c 100644 --- a/Sources/Extensions/Extensions.swift +++ b/Sources/Extensions/Extensions.swift @@ -163,3 +163,63 @@ public extension [String: String] { } } } + +extension UIImage { + + func resized(toMaxSize maxSize: CGFloat = 1000.0) -> UIImage? { + guard let image = self.normalizedImage() else { return nil } + + let width = image.size.width + let height = image.size.height + let aspectRatio = width / height + + var newWidth: CGFloat + var newHeight: CGFloat + + if width <= height { + // Portrait or square + newHeight = min(height, maxSize) + newWidth = newHeight * aspectRatio + if newWidth > maxSize { + newWidth = maxSize + newHeight = newWidth / aspectRatio + } + } else { + // Landscape + newWidth = min(width, maxSize) + newHeight = newWidth / aspectRatio + if newHeight > maxSize { + newHeight = maxSize + newWidth = newHeight * aspectRatio + } + } + + let newSize = CGSize(width: newWidth, height: newHeight) + UIGraphicsBeginImageContextWithOptions(newSize, false, image.scale) + defer { UIGraphicsEndImageContext() } + + image.draw(in: CGRect(origin: .zero, size: newSize)) + return UIGraphicsGetImageFromCurrentImageContext() + } + + /// `Re-orientate` the image to `up`. + func normalizedImage() -> UIImage? + { + if self.imageOrientation == .up + { + return self + } + else + { + UIGraphicsBeginImageContextWithOptions(self.size, false, self.scale) + defer + { + UIGraphicsEndImageContext() + } + + self.draw(in: CGRect(origin: .zero, size: self.size)) + + return UIGraphicsGetImageFromCurrentImageContext() + } + } +} diff --git a/Sources/Model/OFF/OpenFoodFactsLanguage.swift b/Sources/Model/OFF/OpenFoodFactsLanguage.swift index 4f5b373..7631846 100644 --- a/Sources/Model/OFF/OpenFoodFactsLanguage.swift +++ b/Sources/Model/OFF/OpenFoodFactsLanguage.swift @@ -195,7 +195,7 @@ public enum OpenFoodFactsLanguage: String, CaseIterable, Identifiable, Equatable case ZULU case UNDEFINED - var info: (code: String, description: String) { + public var info: (code: String, description: String) { switch self { case .ENGLISH: return ("en", "English") diff --git a/Sources/Model/OFF/Product.swift b/Sources/Model/OFF/Product.swift index c4af88b..0ffb823 100644 --- a/Sources/Model/OFF/Product.swift +++ b/Sources/Model/OFF/Product.swift @@ -46,9 +46,11 @@ public struct Product: Codable, Equatable, Sendable { public let brands: String? public let lang: OpenFoodFactsLanguage? public let quantity: String? - public let packagingQuantity: Double? + public let packagingQuantity: Double + public let packagingQuantityUnit: String public let servingSize: String? - public let servingQuantity: Double? + public let servingQuantity: Double + public let servingQuantityUnit: String public let dataPer: String? public let categories: String? public var nutriments: [String: Any]? @@ -56,27 +58,56 @@ public struct Product: Codable, Equatable, Sendable { public let imageIngredients: String? public let imageNutrition: String? public let keywords: [String]? + public let novaGroup: Double? + public let nutriScore: String? - enum CodingKeys: String, CodingKey { + public enum CodingKeys: String, CodingKey { case code case lang case brands case quantity case packagingQuantity = "product_quantity" + case packagingQuantityUnit = "product_quantity_unit" case categories case images case productName = "product_name" case productNameEn = "product_name_en" case servingSize = "serving_size" case servingQuantity = "serving_quantity" + case servingQuantityUnit = "serving_quantity_unit" case dataPer = "nutrition_data_per" case nutriments = "nutriments" case imageFront = "image_front_url" case imageIngredients = "image_ingredients_url" case imageNutrition = "image_nutrition_url" + case novaGroup = "nova_group" + case nutriScore = "nutriscore_grade" case keywords = "_keywords" } + public init(code: String, productName: String? = nil, productNameEn: String? = nil, brands: String? = nil, lang: OpenFoodFactsLanguage = .ENGLISH, quantity: String? = nil, packagingQuantity: Double = 100, packagingQuantityUnit: String = "g", servingSize: String? = nil, servingQuantity: Double = 100, servingQuantityUnit: String = "g", dataPer: String? = nil, categories: String? = nil, nutriments: [String: Any]? = nil, imageFront: String? = nil, imageIngredients: String? = nil, imageNutrition: String? = nil, keywords: [String]? = nil, novaGroup: Double? = nil, nutriScore: String? = nil) { + self.code = code + self.productName = productName + self.productNameEn = productNameEn + self.brands = brands + self.lang = lang + self.quantity = quantity + self.packagingQuantity = packagingQuantity + self.packagingQuantityUnit = packagingQuantityUnit + self.servingSize = servingSize + self.servingQuantity = servingQuantity + self.servingQuantityUnit = servingQuantityUnit + self.dataPer = dataPer + self.categories = categories + self.nutriments = nutriments + self.imageFront = imageFront + self.imageIngredients = imageIngredients + self.imageNutrition = imageNutrition + self.keywords = keywords + self.novaGroup = novaGroup + self.nutriScore = nutriScore + } + public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) code = try container.decode(String.self, forKey: .code) @@ -91,23 +122,28 @@ public struct Product: Codable, Equatable, Sendable { imageIngredients = try container.decodeIfPresent(String.self, forKey: .imageIngredients) imageNutrition = try container.decodeIfPresent(String.self, forKey: .imageNutrition) keywords = try container.decodeIfPresent([String].self, forKey: .keywords) + nutriScore = try container.decodeIfPresent(String.self, forKey: .nutriScore) + novaGroup = try container.decodeIfPresent(Double.self, forKey: .novaGroup) + servingQuantityUnit = try container.decodeIfPresent(String.self, forKey: .servingQuantityUnit) ?? "g" + packagingQuantityUnit = try container.decodeIfPresent(String.self, forKey: .packagingQuantityUnit) ?? "g" if let packagingQuantityValue = try? container.decode(Double.self, forKey: .packagingQuantity) { packagingQuantity = packagingQuantityValue - } else if let packagingQuantityString = try? container.decode(String.self, forKey: .packagingQuantity), - let packagingQuantityValue = Double(packagingQuantityString) { - packagingQuantity = packagingQuantityValue + } else if let packagingQuantityString = try? container.decode(String.self, forKey: .packagingQuantity) { + let cleanedString = packagingQuantityString.filter { $0.isNumber || $0 == "." || $0 == "," } + packagingQuantity = Double(cleanedString) ?? 0.0 + } else { - packagingQuantity = nil + packagingQuantity = 100 } - + if let servingQuantityValue = try? container.decode(Double.self, forKey: .servingQuantity) { servingQuantity = servingQuantityValue - } else if let servingQuantityString = try? container.decode(String.self, forKey: .servingQuantity), - let servingQuantityValue = Double(servingQuantityString) { - servingQuantity = servingQuantityValue + } else if let servingQuantityString = try? container.decode(String.self, forKey: .servingQuantity) { + let cleanedString = servingQuantityString.filter { $0.isNumber || $0 == "." || $0 == "," } + servingQuantity = Double(cleanedString) ?? 0.0 } else { - servingQuantity = nil + servingQuantity = 100 } if let nutrimentsContainer = try? container.nestedContainer(keyedBy: AnyCodingKey.self, forKey: .nutriments) { @@ -136,15 +172,19 @@ public struct Product: Codable, Equatable, Sendable { try container.encodeIfPresent(productNameEn, forKey: .productNameEn) try container.encodeIfPresent(quantity, forKey: .quantity) try container.encodeIfPresent(packagingQuantity, forKey: .packagingQuantity) + try container.encodeIfPresent(packagingQuantityUnit, forKey: .packagingQuantityUnit) try container.encodeIfPresent(servingSize, forKey: .servingSize) try container.encodeIfPresent(servingQuantity, forKey: .servingQuantity) + try container.encodeIfPresent(servingQuantityUnit, forKey: .servingQuantityUnit) try container.encodeIfPresent(dataPer, forKey: .dataPer) try container.encodeIfPresent(categories, forKey: .categories) try container.encodeIfPresent(imageFront, forKey: .imageFront) try container.encodeIfPresent(imageIngredients, forKey: .imageIngredients) try container.encodeIfPresent(imageNutrition, forKey: .imageNutrition) try container.encodeIfPresent(keywords, forKey: .keywords) - try container.encodeIfPresent(self.lang?.rawValue, forKey: .lang) + try container.encodeIfPresent(lang?.rawValue, forKey: .lang) + try container.encodeIfPresent(nutriScore, forKey: .nutriScore) + try container.encodeIfPresent(novaGroup, forKey: .novaGroup) if let nutriments = self.nutriments { var nutrimentsContainer = container.nestedContainer(keyedBy: AnyCodingKey.self, forKey: .nutriments) diff --git a/Sources/Model/OFF/ProductConfiguration.swift b/Sources/Model/OFF/ProductConfiguration.swift index 8a17b95..1e8434e 100644 --- a/Sources/Model/OFF/ProductConfiguration.swift +++ b/Sources/Model/OFF/ProductConfiguration.swift @@ -12,7 +12,7 @@ public struct ProductQueryConfiguration { var languages: [OpenFoodFactsLanguage] var fields: [ProductField]? - init(barcode: String, + public init(barcode: String, languages: [OpenFoodFactsLanguage] = [], country: OpenFoodFactsCountry? = nil, fields: [ProductField]? = nil) { diff --git a/Sources/Model/OFF/ProductField.swift b/Sources/Model/OFF/ProductField.swift index 471d46f..572017a 100644 --- a/Sources/Model/OFF/ProductField.swift +++ b/Sources/Model/OFF/ProductField.swift @@ -7,7 +7,7 @@ import Foundation -enum ProductField: String { +public enum ProductField: String { case barcode = "code" case name = "product_name" case nameInLanguages = "product_name_" diff --git a/Sources/Model/OFF/ProductResponse.swift b/Sources/Model/OFF/ProductResponse.swift index 896b1ce..e9b342b 100644 --- a/Sources/Model/OFF/ProductResponse.swift +++ b/Sources/Model/OFF/ProductResponse.swift @@ -10,31 +10,66 @@ import Foundation public struct ProductResponse: Decodable { /// Possible value for [status]: the operation failed. - static let statusFailure = "failure" + public static let statusFailure = "failure" /// Possible value for [status]: the operation succeeded with warnings. - static let statusWarning = "success_with_warnings" + public static let statusWarning = "success_with_warnings" /// Possible value for [status]: the operation succeeded. - static let statusSuccess = "success" + public static let statusSuccess = "success" /// Possible value for [result.id]: product found - static let resultProductFound = "product_found" + public static let resultProductFound = "product_found" /// Possible value for [result.id]: product not found - static let resultProductNotFound = "product_not_found" + public static let resultProductNotFound = "product_not_found" - let barcode: String? - let status: String? - let product: Product? + public let barcode: String? + public let status: Int? + public let statusVerbose: String? + public let product: Product? - enum CodingKeys: String, CodingKey { + public enum CodingKeys: String, CodingKey { case barcode = "code" case status case product + case statusVerbose = "status_verbose" } - func hasProduct() -> Bool { + public func hasProduct() -> Bool { + guard let status = self.statusVerbose else { return false } + return status == ProductResponse.resultProductFound || status == ProductResponse.statusSuccess || status == ProductResponse.statusWarning + } +} + +public struct ProductResponseV3: Decodable { + + /// Possible value for [status]: the operation failed. + public static let statusFailure = "failure" + + /// Possible value for [status]: the operation succeeded with warnings. + public static let statusWarning = "success_with_warnings" + + /// Possible value for [status]: the operation succeeded. + public static let statusSuccess = "success" + + /// Possible value for [result.id]: product found + public static let resultProductFound = "product_found" + + /// Possible value for [result.id]: product not found + public static let resultProductNotFound = "product_not_found" + + public let barcode: String? + public let status: String? + public let product: Product? + + public enum CodingKeys: String, CodingKey { + case barcode = "code" + case status + case product + } + + public func hasProduct() -> Bool { guard let status = self.status else { return false } return status == ProductResponse.resultProductFound || status == ProductResponse.statusSuccess || status == ProductResponse.statusWarning } diff --git a/Sources/Model/OFF/SendImage.swift b/Sources/Model/OFF/SendImage.swift index 36543e5..b20fa4a 100644 --- a/Sources/Model/OFF/SendImage.swift +++ b/Sources/Model/OFF/SendImage.swift @@ -28,13 +28,13 @@ public enum ImageField: String, CaseIterable, Decodable { public struct SendImage { - var barcode: String - var image: UIImage - var imageField: ImageField + public var barcode: String + public var image: UIImage + public var imageField: ImageField - var imageUri: String? + public var imageUri: String? - init(barcode: String, imageField: ImageField = .front, image: UIImage, imageUri: String? = nil) { + public init(barcode: String, imageField: ImageField = .front, image: UIImage, imageUri: String? = nil) { self.barcode = barcode self.image = image self.imageField = imageField diff --git a/Sources/Model/OFF/UserAgent.swift b/Sources/Model/OFF/UserAgent.swift index 1fea0a5..5cdaa5d 100644 --- a/Sources/Model/OFF/UserAgent.swift +++ b/Sources/Model/OFF/UserAgent.swift @@ -36,9 +36,12 @@ public struct UserAgent: Codable { var result = "" let mirror = Mirror(reflecting: self) - for child in mirror.children { + for (index, child) in mirror.children.enumerated() { if let value = child.value as? String { - result += " - \(value)" + result += "\(value)" + if index < mirror.children.count - 1 { + result += "-" + } } } diff --git a/Sources/Networking/HttpHelper.swift b/Sources/Networking/HttpHelper.swift index 7917068..190b9ea 100644 --- a/Sources/Networking/HttpHelper.swift +++ b/Sources/Networking/HttpHelper.swift @@ -72,8 +72,7 @@ final class HttpHelper { data.append("\(value)\r\n") } - // Files - if let fileData = sendImage.image.pngData() { + if let fileData = sendImage.image.resized()?.jpegData(compressionQuality: 0.8) { data.append("--\(boundary)\r\n") data.append("Content-Disposition: form-data; name=\"\(sendImage.getImageDataKey())\"; filename=\"\(fileData.hashValue)\"\r\n") data.append("Content-Type: application/octet-stream\r\n\r\n") @@ -90,6 +89,7 @@ final class HttpHelper { let (data, _) = try await URLSession.shared.data(for: request) return data } catch { + print("\(#function) \(uri) error: \(error)") throw error } } @@ -140,6 +140,7 @@ final class HttpHelper { return data } catch { // Re-throw the error to be caught by the caller + print("\(#function) \(uri) error: \(error)") throw error } } diff --git a/Sources/Networking/OpenFoodAPIClient.swift b/Sources/Networking/OpenFoodAPIClient.swift index 5e4a1ec..8cfe5a8 100644 --- a/Sources/Networking/OpenFoodAPIClient.swift +++ b/Sources/Networking/OpenFoodAPIClient.swift @@ -53,7 +53,7 @@ final public actor OpenFoodAPIClient { let queryParameters = config.getParametersMap() - guard let uriPath = UriHelper.getUri(path: "/api/v3/product/\(config.barcode)", queryParameters: queryParameters) else { + guard let uriPath = UriHelper.getUri(path: "/api/v2/product/\(config.barcode)", queryParameters: queryParameters) else { throw NSError(domain: "Couldn't compose uri for \(#function) call", code: 400) } do { @@ -94,7 +94,7 @@ final public actor OpenFoodAPIClient { "limit": "\(limit)", ] - guard let uri = UriHelper.getUri(path: "/api/v3/taxonomy_suggestions", queryParameters: queryParameters) else { + guard let uri = UriHelper.getUri(path: "/api/v2/taxonomy_suggestions", queryParameters: queryParameters) else { throw NSError(domain: "Couldn't compose uri for \(#function) call", code: 400) } diff --git a/Sources/Networking/UriHelper.swift b/Sources/Networking/UriHelper.swift index 4509fd5..f809dc3 100644 --- a/Sources/Networking/UriHelper.swift +++ b/Sources/Networking/UriHelper.swift @@ -25,7 +25,7 @@ final class UriHelper { var components = URLComponents() components.scheme = OFFApi.scheme - components.host = OFFConfig.shared.api.host(for: .world) + components.host = OFFConfig.shared.api.host(for: .none) components.path = path components.queryItems = allQueryParams?.map { URLQueryItem(name: $0.key, value: $0.value) } diff --git a/Sources/OFFApi.swift b/Sources/OFFApi.swift index bb5cf07..6f44fcb 100644 --- a/Sources/OFFApi.swift +++ b/Sources/OFFApi.swift @@ -41,6 +41,7 @@ public struct OFFApi { case images case events case taxonomies + case none } func host(for endpoint: Endpoint) -> String { @@ -64,8 +65,12 @@ public struct OFFApi { subdomain = "static" case .events: subdomain = "events" + case .none: + subdomain = "" } + if subdomain.isEmpty { return domain } + return "\(subdomain).\(domain)" } } diff --git a/Sources/OFFConfig.swift b/Sources/OFFConfig.swift index 58c460d..decd864 100644 --- a/Sources/OFFConfig.swift +++ b/Sources/OFFConfig.swift @@ -53,10 +53,10 @@ final public class OFFConfig { withSystem: Bool = true, system: String = "", withId: Bool = true, id: String = "") -> String { var appInfo = "" - let infoDelimiter = " - " + let infoDelimiter = "-" if withName { - appInfo += infoDelimiter + name + appInfo += name } if withVersion { appInfo += infoDelimiter + version @@ -79,9 +79,9 @@ final public class OFFConfig { let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "" - let version = "\(appVersion)+\(buildNumber)" - let system = "\(UIDevice.current.systemName)+\(UIDevice.current.systemVersion)" - let comment = getAppInfoComment(name: appName, version: version, system: system, id: uuid) + let version = "\(appVersion)_\(buildNumber)" + let system = "\(UIDevice.current.systemName)_\(UIDevice.current.systemVersion)" + let comment = getAppInfoComment(name: appName, version: version, system: system, id: uuid.replacingOccurrences(of: "-", with: "")) self.userAgent = UserAgent( name: appName, diff --git a/Sources/ProductPage.swift b/Sources/ProductPage.swift index b5bde01..da3f83f 100644 --- a/Sources/ProductPage.swift +++ b/Sources/ProductPage.swift @@ -37,74 +37,76 @@ public struct ProductPage: View { } public var body: some View { - Group { - switch pageConfig.pageState { - case .loading, .completed, .error: - PageOverlay(state: $pageConfig.pageState) - case .productDetails: - ProductDetails(barcode: barcode) - .environmentObject(pageConfig) - .environmentObject(imagesHelper) - .actionSheet(isPresented: $imagesHelper.isPresentedSourcePicker) { () -> ActionSheet in - ActionSheet( - title: Text("Choose source"), - message: Text("Please choose your preferred source to add product's image"), - buttons: [ - .default(Text("Camera"), action: { - self.imagesHelper.isPresented = true - self.imagesHelper.source = .camera - }), - .default(Text("Gallery"), action: { - self.imagesHelper.isPresented = true - self.imagesHelper.source = .photoLibrary - }), - .cancel() - ] - ) - } - .fullScreenCover(isPresented: $imagesHelper.isPresentedImagePreview, content: { - ImageViewer( - viewerShown: $imagesHelper.isPresentedImagePreview, - image: $imagesHelper.previewImage, - closeButtonTopRight: true - ) - }) - .fullScreenCover(isPresented: $imagesHelper.isPresented) { - ImagePickerView( - isPresented: $imagesHelper.isPresented, - image: pageConfig.binding(for: imagesHelper.imageFieldToEdit), - source: $imagesHelper.source - ) { withImage in - imagesHelper.showingCropper = withImage - }.ignoresSafeArea() - } - .fullScreenCover(isPresented: $imagesHelper.showingCropper, content: { - ImageCropper( - image: pageConfig.binding(for: imagesHelper.imageFieldToEdit), - isPresented: $imagesHelper.showingCropper, - errorMessage: $pageConfig.errorMessage - ).ignoresSafeArea() - }) - .toolbar { - ToolbarItem(placement: .topBarLeading) { - Button("Cancel") { - dismiss() - } + NavigationView { + Group { + switch pageConfig.pageState { + case .loading, .completed, .error: + PageOverlay(state: $pageConfig.pageState) + case .productDetails: + ProductDetails(barcode: barcode) + .environmentObject(pageConfig) + .environmentObject(imagesHelper) + .actionSheet(isPresented: $imagesHelper.isPresentedSourcePicker) { () -> ActionSheet in + ActionSheet( + title: Text("Choose source"), + message: Text("Please choose your preferred source to add product's image"), + buttons: [ + .default(Text("Camera"), action: { + self.imagesHelper.isPresented = true + self.imagesHelper.source = .camera + }), + .default(Text("Gallery"), action: { + self.imagesHelper.isPresented = true + self.imagesHelper.source = .photoLibrary + }), + .cancel() + ] + ) } - ToolbarItem(placement: .topBarTrailing) { - Button("Submit") { - if (pageConfig.getMissingFields().isEmpty) { - Task { - await pageConfig.uploadAllProductData(barcode: barcode) - } - } else { - pageConfig.errorMessage = ErrorAlert( - message: "Fields: \(self.pageConfig.getMissingFieldsMessage())", - title: "Missing required data") + .fullScreenCover(isPresented: $imagesHelper.isPresentedImagePreview, content: { + ImageViewer( + viewerShown: $imagesHelper.isPresentedImagePreview, + image: $imagesHelper.previewImage, + closeButtonTopRight: true + ) + }) + .fullScreenCover(isPresented: $imagesHelper.isPresented) { + ImagePickerView( + isPresented: $imagesHelper.isPresented, + image: pageConfig.binding(for: imagesHelper.imageFieldToEdit), + source: $imagesHelper.source + ) { withImage in + imagesHelper.showingCropper = withImage + }.ignoresSafeArea() + } + .fullScreenCover(isPresented: $imagesHelper.showingCropper, content: { + ImageCropper( + image: pageConfig.binding(for: imagesHelper.imageFieldToEdit), + isPresented: $imagesHelper.showingCropper, + errorMessage: $pageConfig.errorMessage + ).ignoresSafeArea() + }) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Cancel") { + dismiss() } - }.disabled(!pageConfig.isInitialised) + } + ToolbarItem(placement: .topBarTrailing) { + Button("Submit") { + if (pageConfig.getMissingFields().isEmpty) { + Task { + await pageConfig.uploadAllProductData(barcode: barcode) + } + } else { + pageConfig.errorMessage = ErrorAlert( + message: "Fields: \(self.pageConfig.getMissingFieldsMessage())", + title: "Missing required data") + } + }.disabled(!pageConfig.isInitialised) + } } - } + } } } .navigationBarBackButtonHidden() @@ -119,7 +121,6 @@ public struct ProductPage: View { })) }) .onAppear(perform: { - UIApplication.shared.addTapGestureRecognizer() Task(priority: .userInitiated) { await pageConfig.fetchData(barcode: barcode) } diff --git a/Sources/ViewModels/ProductPageConfig.swift b/Sources/ViewModels/ProductPageConfig.swift index b72b374..57a1ebb 100644 --- a/Sources/ViewModels/ProductPageConfig.swift +++ b/Sources/ViewModels/ProductPageConfig.swift @@ -81,11 +81,8 @@ final class ProductPageConfig: ObservableObject { self.orderedNutrients = orderedNutrients self.nutrientsMeta = nutrientsMeta - self.pageState = .completed self.isInitialised = true self.pageType = await determinePageType(response: productResponse) - // FIXME: find a way to show animation states without such workarounds - try await Task.sleep(nanoseconds: 1_000_000_000 * UInt64(PageOverlay.completedAnimDuration)) self.pageState = .productDetails } catch { @@ -175,25 +172,21 @@ final class ProductPageConfig: ObservableObject { } } + @MainActor func uploadAllProductData(barcode: String) async { - await MainActor.run { - self.pageState = .loading - } + self.pageState = .loading await sendAllImages(barcode: barcode) do { let productBody = try await composeProductBody(barcode: barcode) try await OpenFoodAPIClient.shared.saveProduct(product: productBody) let productResponse = try await OpenFoodAPIClient.shared.getProduct(config: ProductQueryConfiguration(barcode: barcode)) - await MainActor.run { - self.pageState = .completed - } + + self.pageState = .completed try await Task.sleep(nanoseconds: 1_000_000_000 * UInt64(PageOverlay.completedAnimDuration)) - await MainActor.run { - self.pageState = ProductPageState.productDetails - self.submittedProduct = productResponse.product - } + self.pageState = ProductPageState.productDetails + self.submittedProduct = productResponse.product } catch { await MainActor.run { self.pageState = .error diff --git a/Sources/openapi-generator-config.yaml b/Sources/openapi-generator-config.yaml new file mode 100644 index 0000000..ecefb47 --- /dev/null +++ b/Sources/openapi-generator-config.yaml @@ -0,0 +1,3 @@ +generate: + - types + - client diff --git a/Sources/openapi.yaml b/Sources/openapi.yaml new file mode 100644 index 0000000..8320beb --- /dev/null +++ b/Sources/openapi.yaml @@ -0,0 +1,599 @@ +openapi: 3.1.0 +info: + title: Open Food Facts Open API + description: | + As a developer, the Open Food Facts API allows you to get information + and contribute to the products database. You can create great apps to + help people make better food choices and also provide data to enhance the database. + termsOfService: "https://world.openfoodfacts.org/terms-of-use" + contact: + name: Open Food Facts + url: "https://slack.openfoodfacts.org/" + email: reuse@openfoodfacts.org + license: + name: "data: ODbL" + url: "https://opendatacommons.org/licenses/odbl/summary/index.html" + # can't use url and identifier - use x-identifier + x-identifier: "ODbL-1.0" + version: "2" +externalDocs: + description: | + Please read the API introduction before using this API. + url: https://openfoodfacts.github.io/openfoodfacts-server/api/ +servers: + - description: dev + url: "https://world.openfoodfacts.net" + - url: "https://world.openfoodfacts.org" + description: prod +paths: + "/api/v2/product/{barcode}": + get: + tags: + - Read Requests + summary: Get information for a specific product by barcode + parameters: + - name: barcode + in: path + description: | + The barcode of the product to be fetched + required: true + style: simple + explode: false + schema: + type: string + example: "3017620422003" + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: ./responses/get_product_by_barcode.yaml + examples: + spread-example: + $ref: ./examples/get_product_by_barcode_spread.yaml + + description: | + A product can be fetched via its unique barcode. + It returns all the details of that product response. + operationId: get-product-by-barcode + "/api/v2/product/{barcode}?fields=knowledge_panels": + get: + tags: + - Read Requests + summary: | + Get Knowledge panels for a specific product by barcode + (special case of get product) + parameters: + - name: barcode + in: path + description: | + The barcode of the product to be fetched + required: true + style: simple + explode: false + schema: + type: string + example: "3017620422003" + responses: + "200": + description: OK + content: + application/json: + schema: + allOf: + - $ref: ./responses/get_product_by_barcode_base.yaml + - type: object + properties: + product: + $ref: ./schemas/product_knowledge_panels.yaml + description: | + Knowledge panels gives high leve informations about a product, + ready to display. + This is used by open food facts website, + and by the official mobile application + operationId: get-product-by-barcode-knowledge-panels + /cgi/product_image_upload.pl: + post: + tags: + - Write Requests + summary: Add a Photo to an Existing Product + operationId: get-cgi-product_image_upload.pl + description: | + Photos are source and proof of data. + The first photo uploaded for a product is + auto-selected as the product’s “front” photo.' + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: ./responses/add_photo_to_existing_product.yaml + requestBody: + content: + multipart/form-data: + schema: + $ref: ./requestBodies/add_photo_to_existing_product.yaml + description: "" + /cgi/ingredients.pl: + parameters: [] + get: + summary: Performing OCR on a Product + operationId: get-cgi-ingredients.pl + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: ./responses/ocr_on_product.yaml + description: | + Open Food Facts uses optical character recognition (OCR) to retrieve nutritional data and other information from the product labels. + parameters: + - $ref: "#/components/parameters/id" + - $ref: "#/components/parameters/code" + - $ref: "#/components/parameters/process_image" + - $ref: "#/components/parameters/ocr_engine" + tags: + - Read Requests + /cgi/product_image_crop.pl: + post: + summary: Crop A Photo + operationId: post-cgi-product_image_crop.pl + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: {} + description: | + Cropping is only relevant for editing existing products. + You cannot crop an image the first time you upload it to the system. + parameters: [] + requestBody: + content: + multipart/form-data: + schema: + $ref: ./requestBodies/crop_a_photo.yaml + required: true + tags: + - Write Requests + get: + summary: Rotate A Photo + operationId: get-cgi-product_image_crop.pl + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: ./responses/rotate_a_photo.yaml + description: | + Although we recommend rotating photos manually and uploading a new version of the image, + the OFF API allows you to make api calls to automate this process. + You can rotate existing photos by setting the angle to 90º, 180º, or 270º clockwise. + parameters: + - $ref: "#/components/parameters/code" + - $ref: "#/components/parameters/id" + - $ref: "#/components/parameters/imgid" + - $ref: "#/components/parameters/angle" + tags: + - Write Requests + /cgi/product_image_unselect.pl: + post: + summary: Unselect A Photo + requestBody: + content: + multipart/form-data: + schema: + $ref: ./requestBodies/unselect_a_photo.yaml + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + status: + type: string + description: status of the unselect operation + example: status ok + status_code: + type: number + description: status code of the operation + example: 0 + imagefield: + type: string + example: front_fr + description: image field that was unselected + + /cgi/product_jqm2.pl: + post: + summary: Add or Edit A Product + operationId: post-cgi-product_jqm2.pl + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: ./responses/add_or_edit_a_product.yaml + parameters: [] + requestBody: + content: + multipart/form-data: + schema: + allOf: + - $ref: ./requestBodies/add_or_edit_a_product.yaml + - $ref: ./requestBodies/change_ref_properties.yaml + tags: + - Write Requests + description: | + This updates a product. + + Note: If the barcode exists then you will be editing the existing product, + However if it doesn''t you will be creating a new product with that unique barcode, + and adding properties to the product. + /api/v2/search: + get: + summary: Search for Products + tags: + - Read Requests + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: ./responses/search_for_products.yaml + operationId: get-search + description: | + Search request allows you to get products that match your search criteria. + + It allows you create many custom APIs for your use case. + + If the search query parameter has 2 possible values, they are seperated by a comma(,). + When filtering via a parameter that has different language codes like `fr`, `de` or `en`, specify the language code in the parameter name e.g `categories_tags_en` + + **Important:** search API v2 does not support full text request (search_term), + you have to use [search API v1](https://wiki.openfoodfacts.org/API/Read/Search) for that. + Upcoming [search-a-licious project](https://github.com/openfoodfacts/search-a-licious) will fix that. + + ### Limiting results + + You can limit the size of returned objects thanks to the `fields` object (see below). + + eg: `fields=code,product_name,brands,attribute_groups`` + + Please use it as much as possible to avoid overloading the servers. + + The search use pagination, see `page` and `page_size` parameters. + + **Beware:** the `page_count` data in item is a bit counter intuitive…, read the description. + + ### Conditions on tags + + All `_tags`` parameters accepts either: + + * a single value + * or a comma-separated list of values (doing a AND) + * or a pipe separated list of values (doing a OR) + + You can exclude terms by using a "-" prefix. + + For taxonomized entries, you might either use the tag id (recommended), + or a known synonym (without language prefix) + + * `labels_tags=en:organic,en:fair-trade` find items that are fair-trade AND organic + * `labels_tags=en:organic|en:fair-trade` find items that are fair-trade OR organic + * `labels_tags=en:organic,en:-fair-trade` find items that are organic BUT NOT fair-trade + + + ### Conditions on nutriments + + To get a list of nutrients + + You can either query on nutrient per 100g (`_100g` suffix) + or per serving (`serving` suffix). + + You can also add `_prepared_` + to get the nutrients in the prepared product instead of as sold. + + You can add a comparison operator and value to the parameter name + to get products with nutrient above or bellow a value. + If you use a parameter value it exactly match it. + + * `energy-kj_100g<200` products where energy in kj for 100g is less than 200kj + * `sugars_serving>10` products where sugar per serving is greater than 10g + * `saturated-fat_100g=1` products where saturated fat per 100g is exactly 10g + * `salt_prepared_serving<0.1` products where salt per serving for prepared product is less than 0.1g + + ### More references + + See also [wiki page](https://wiki.openfoodfacts.org/Open_Food_Facts_Search_API_Version_2) + + parameters: + # all tags parameters + - $ref: "./schemas/tags_parameters.yaml#/properties/additives_tags" + - $ref: "./schemas/tags_parameters.yaml#/properties/allergens_tags" + - $ref: "./schemas/tags_parameters.yaml#/properties/brands_tags" + - $ref: "./schemas/tags_parameters.yaml#/properties/categories_tags" + - $ref: "./schemas/tags_parameters.yaml#/properties/countries_tags" + - $ref: "./schemas/tags_parameters.yaml#/properties/emb_codes_tags" + - $ref: "./schemas/tags_parameters.yaml#/properties/labels_tags" + - $ref: "./schemas/tags_parameters.yaml#/properties/manufacturing_places_tags" + - $ref: "./schemas/tags_parameters.yaml#/properties/nutrition_grades_tags" + - $ref: "./schemas/tags_parameters.yaml#/properties/origins_tags" + - $ref: "./schemas/tags_parameters.yaml#/properties/packaging_tags" + - $ref: "./schemas/tags_parameters.yaml#/properties/purchase_places_tags" + - $ref: "./schemas/tags_parameters.yaml#/properties/states_tags" + - $ref: "./schemas/tags_parameters.yaml#/properties/stores_tags" + - $ref: "./schemas/tags_parameters.yaml#/properties/traces_tags" + - $ref: "./schemas/tags_parameters.yaml#/properties/tag_name_with_language_code" + - $ref: "./schemas/nutrition_search.yaml#/properties/nutrient_lower_than" + - $ref: "./schemas/nutrition_search.yaml#/properties/nutrient_greater_than" + - $ref: "./schemas/nutrition_search.yaml#/properties/nutrient_equal" + - $ref: "#/components/parameters/fields" + - $ref: "#/components/parameters/sort_by" + - $ref: "#/components/parameters/page" + - $ref: "#/components/parameters/page_size" + parameters: [] + /cgi/suggest.pl: + get: + summary: Get Suggestions to Aid Adding/Editing Products + tags: + - Read Requests + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + operationId: get-cgi-suggest.pl + parameters: + - $ref: "#/components/parameters/tagtype" + - $ref: "#/components/parameters/term" + description: | + For example , Dave is looking for packaging_shapes that contain the term "fe", + all packaging_shapes containing "fe" will be returned. + This is useful if you have a search in your application, + for a specific product field. + /cgi/nutrients.pl: + get: + summary: Get a nested list of nutrients that can be displayed in the nutrition facts table for a specific country and language + tags: + - Read Requests + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: ./responses/get_nutrients.yaml + operationId: get-cgi-nutrients.pl + parameters: + - $ref: "#/components/parameters/cc" + - $ref: "#/components/parameters/lc" + description: | + Used to display the nutrition facts table of a product, or to display a form to input those nutrition facts. + /api/v2/attribute_groups: + get: + summary: Get the list of attributes available for personal search. + description: | + Attributes are at the heart of personal search. + They score the products according to different criterias, + which could then be matched to a user's preferences. + + This API helps you list attributes and display them in your application, + for the user to choose the importance of each criteria. + + note: /api/v2/attribute_groups_{lc} is also a valid route, but consider it deprecated + tags: + - Read Requests + - Personal search + operationId: get-attribute-groups + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: ./responses/get_attribute_groups.yaml + parameters: + - $ref: "#/components/parameters/lc" + /api/v2/preferences: + get: + summary: | + Get the weights corresponding to attributes preferences + to compute personal product + tags: + - Read Requests + - Personal search + operationId: get-preferences + parameters: + - $ref: "#/components/parameters/lc" + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: ./responses/get_preferences.yaml +components: + schemas: + "Product-Base": + $ref: ./schemas/product_base.yaml + "Product-Misc": + $ref: ./schemas/product_misc.yaml + "Product-Tags": + $ref: ./schemas/product_tags.yaml + "Product-Nutrition": + $ref: ./schemas/product_nutrition.yaml + "Product-Ingredients": + $ref: ./schemas/product_ingredients.yaml + "Product-Images": + $ref: ./schemas/product_images.yaml + "Product-Eco-Score": + $ref: ./schemas/product_ecoscore.yaml + "Product-Metadata": + $ref: ./schemas/product_meta.yaml + "Product-Data-Quality": + $ref: ./schemas/product_quality.yaml + "Product-Knowledge-Panels": + $ref: ./schemas/product_knowledge_panels.yaml + "Product-Attribute-Groups": + $ref: "./schemas/product_attribute_groups.yaml" + Product: + $ref: ./schemas/product.yaml + parameters: + id: + schema: + type: string + example: ingredients_en + in: query + name: id + required: true + cc: + schema: + type: string + example: "us" + in: query + name: cc + required: false + description: "2 letter code of the country of the user. Used for localizing some fields in returned values (e.g. knowledge panels). If not passed, the country may be inferred by the IP address of the request." + lc: + schema: + type: string + example: "fr" + in: query + name: lc + required: false + description: | + 2 letter code of the language of the user. + Used for localizing some fields in returned values (e.g. knowledge panels). + If not passed, the language may be inferred by the Accept-Language header of the request, + or from the domain name prefix. + code: + schema: + type: string + example: "4251105501381" + in: query + name: code + description: Barcode of the product + required: true + process_image: + schema: + type: string + example: "1" + in: query + name: process_image + required: true + ocr_engine: + schema: + type: string + example: google_cloud_vision + in: query + name: ocr_engine + required: true + imgid: + schema: + type: string + example: "1" + in: query + name: imgid + required: true + angle: + schema: + type: string + example: "90" + in: query + name: angle + required: true + page: + schema: + type: int + example: 24 + in: query + name: page + description: | + The page number you request to view (eg. in search results spanning multiple pages) + page_size: + schema: + type: int + example: 24 + in: query + name: page_size + description: | + The number of elements should be sent per page + sort_by: + schema: + type: string + example: product_name + enum: + - product_name + - last_modified_t + - scans_n + - unique_scans_n + - created_t + - completeness + - popularity_key + - nutriscore_score + - nova_score + - nothing + - ecoscore_score + in: query + name: sort_by + description: | + The allowed values used to sort/order the search results. + + * `product_name` sorts on name + * `ecoscore_score`, `nova_score`, `nutriscore_score` rank on the [Eco-Score](https://world.openfoodfacts.org/eco-score-the-environmental-impact-of-food-products), [Nova](https://world.openfoodfacts.org/nova), or [Nutri-Score](https://world.openfoodfacts.org/nutriscore) + * `scans_n`, `unique_scans_n` and `popularity_key` are about product popularity: number of scans on unique scans, rank of product + * `created_t`, `last_modified_t`, are about creation and modification dates + * `nothing`, tells not to sort at all (because if you do not provide the sort_by argument we default to sorting on popularity (for food) or last modification date) + fields: + schema: + type: string + example: "code,product_name" + in: query + name: fields + description: | + The fields to be returned from the product object can also be limited. + If not specified, it returns the entire product object response. + knowledge_panels_included: + schema: + type: string + example: "heatlh_card, environment_card" + in: query + name: knowledge_panels_included + description: | + When knowledge_panels are requested, you can specify which panels should be in the response. All the others will be excluded. + knowledge_panels_excluded: + schema: + type: string + example: "heatlh_card, environment_card" + in: query + name: knowledge_panels_excluded + description: | + When knowledge_panels are requested, you can specify which panels to exclude from the response. All the others will be included. + If a panel is both excluded and included (with the knowledge_panels_excluded parameter), it will be excluded. + tagtype: + schema: + type: string + example: additives + in: query + name: tagtype + term: + schema: + type: string + example: f + in: query + name: term +tags: + - name: Read Requests + - name: Write Requests