- SwiftUI + async/await architecture - Barcode scanning with AVFoundation - Product display with score ring, NOVA badge, nutrition - Alternatives with sort/filter - Auth (login/register) - Favorites & history - Account management - Dark theme - Connected to food.payfrit.com API (Open Food Facts proxy) Co-Authored-By: Claude <noreply@anthropic.com>
174 lines
5.8 KiB
Swift
174 lines
5.8 KiB
Swift
import Foundation
|
|
|
|
struct Product: Identifiable, Hashable {
|
|
static func == (lhs: Product, rhs: Product) -> Bool {
|
|
lhs.id == rhs.id
|
|
}
|
|
|
|
func hash(into hasher: inout Hasher) {
|
|
hasher.combine(id)
|
|
}
|
|
|
|
let id: Int
|
|
let barcode: String
|
|
let name: String
|
|
let brand: String
|
|
let imageUrl: String?
|
|
let score: Int // 0-100 weighted index
|
|
let novaGroup: Int // 1-4
|
|
let servingSize: String
|
|
let calories: Double
|
|
let fat: Double
|
|
let saturatedFat: Double
|
|
let carbs: Double
|
|
let sugar: Double
|
|
let fiber: Double
|
|
let protein: Double
|
|
let sodium: Double
|
|
let ingredients: String
|
|
let dietaryTags: [String] // ["vegan", "gluten-free", etc.]
|
|
|
|
init(json: [String: Any]) {
|
|
// Handle nested structures from API
|
|
let rating = json["Rating"] as? [String: Any] ?? [:]
|
|
let nutrition = json["Nutrition"] as? [String: Any] ?? [:]
|
|
let dietary = json["Dietary"] as? [String: Any] ?? [:]
|
|
|
|
id = JSON.parseInt(json["id"] ?? json["ID"] ?? json["productId"])
|
|
barcode = JSON.parseString(json["barcode"] ?? json["Barcode"] ?? json["upc"])
|
|
name = JSON.parseString(json["name"] ?? json["Name"] ?? json["productName"])
|
|
brand = JSON.parseString(json["brand"] ?? json["Brand"] ?? json["brandName"])
|
|
imageUrl = JSON.parseOptionalString(json["imageUrl"] ?? json["ImageURL"] ?? json["image"])
|
|
|
|
// Score from Rating.OverallScore or top-level
|
|
score = JSON.parseInt(rating["OverallScore"] ?? json["score"] ?? json["Score"])
|
|
novaGroup = JSON.parseInt(json["novaGroup"] ?? json["NovaGroup"] ?? json["nova"])
|
|
|
|
// Nutrition from nested object or top-level
|
|
servingSize = JSON.parseString(nutrition["ServingSize"] ?? json["servingSize"] ?? json["ServingSize"])
|
|
calories = JSON.parseDouble(nutrition["Calories"] ?? json["calories"] ?? json["Calories"])
|
|
fat = JSON.parseDouble(nutrition["Fat"] ?? json["fat"] ?? json["Fat"])
|
|
saturatedFat = JSON.parseDouble(nutrition["SaturatedFat"] ?? json["saturatedFat"] ?? json["SaturatedFat"])
|
|
carbs = JSON.parseDouble(nutrition["Carbohydrates"] ?? json["carbs"] ?? json["Carbs"])
|
|
sugar = JSON.parseDouble(nutrition["Sugars"] ?? json["sugar"] ?? json["Sugar"])
|
|
fiber = JSON.parseDouble(nutrition["Fiber"] ?? json["fiber"] ?? json["Fiber"])
|
|
protein = JSON.parseDouble(nutrition["Protein"] ?? json["protein"] ?? json["Protein"])
|
|
sodium = JSON.parseDouble(nutrition["Sodium"] ?? json["sodium"] ?? json["Sodium"])
|
|
|
|
ingredients = JSON.parseString(json["ingredients"] ?? json["Ingredients"])
|
|
|
|
// Build dietary tags from Dietary object or use existing array
|
|
var tags: [String] = []
|
|
if JSON.parseBool(dietary["IsVegan"]) { tags.append("Vegan") }
|
|
if JSON.parseBool(dietary["IsVegetarian"]) { tags.append("Vegetarian") }
|
|
if JSON.parseBool(dietary["IsGlutenFree"]) { tags.append("Gluten-Free") }
|
|
if JSON.parseBool(dietary["IsDairyFree"]) { tags.append("Dairy-Free") }
|
|
if JSON.parseBool(dietary["IsNutFree"]) { tags.append("Nut-Free") }
|
|
if JSON.parseBool(dietary["IsOrganic"]) { tags.append("Organic") }
|
|
|
|
if tags.isEmpty {
|
|
dietaryTags = JSON.parseStringArray(json["dietaryTags"] ?? json["DietaryTags"] ?? json["tags"])
|
|
} else {
|
|
dietaryTags = tags
|
|
}
|
|
}
|
|
|
|
init(
|
|
id: Int,
|
|
barcode: String,
|
|
name: String,
|
|
brand: String,
|
|
imageUrl: String? = nil,
|
|
score: Int,
|
|
novaGroup: Int,
|
|
servingSize: String,
|
|
calories: Double,
|
|
fat: Double,
|
|
saturatedFat: Double,
|
|
carbs: Double,
|
|
sugar: Double,
|
|
fiber: Double,
|
|
protein: Double,
|
|
sodium: Double,
|
|
ingredients: String,
|
|
dietaryTags: [String]
|
|
) {
|
|
self.id = id
|
|
self.barcode = barcode
|
|
self.name = name
|
|
self.brand = brand
|
|
self.imageUrl = imageUrl
|
|
self.score = score
|
|
self.novaGroup = novaGroup
|
|
self.servingSize = servingSize
|
|
self.calories = calories
|
|
self.fat = fat
|
|
self.saturatedFat = saturatedFat
|
|
self.carbs = carbs
|
|
self.sugar = sugar
|
|
self.fiber = fiber
|
|
self.protein = protein
|
|
self.sodium = sodium
|
|
self.ingredients = ingredients
|
|
self.dietaryTags = dietaryTags
|
|
}
|
|
|
|
// MARK: - Computed Properties
|
|
var imageURL: URL? {
|
|
guard let imageUrl = imageUrl else { return nil }
|
|
return URL(string: imageUrl)
|
|
}
|
|
|
|
var scoreColor: ScoreColor {
|
|
switch score {
|
|
case 80...100: return .excellent
|
|
case 50..<80: return .good
|
|
case 30..<50: return .fair
|
|
default: return .poor
|
|
}
|
|
}
|
|
|
|
var novaColor: NovaColor {
|
|
switch novaGroup {
|
|
case 1: return .nova1
|
|
case 2: return .nova2
|
|
case 3: return .nova3
|
|
default: return .nova4
|
|
}
|
|
}
|
|
|
|
enum ScoreColor {
|
|
case excellent, good, fair, poor
|
|
|
|
var color: String {
|
|
switch self {
|
|
case .excellent: return "scoreGreen"
|
|
case .good: return "scoreYellow"
|
|
case .fair: return "scoreOrange"
|
|
case .poor: return "scoreRed"
|
|
}
|
|
}
|
|
}
|
|
|
|
enum NovaColor {
|
|
case nova1, nova2, nova3, nova4
|
|
|
|
var color: String {
|
|
switch self {
|
|
case .nova1: return "novaGreen"
|
|
case .nova2: return "novaYellow"
|
|
case .nova3: return "novaOrange"
|
|
case .nova4: return "novaRed"
|
|
}
|
|
}
|
|
|
|
var label: String {
|
|
switch self {
|
|
case .nova1: return "Unprocessed"
|
|
case .nova2: return "Processed ingredients"
|
|
case .nova3: return "Processed"
|
|
case .nova4: return "Ultra-processed"
|
|
}
|
|
}
|
|
}
|
|
}
|