- 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>
281 lines
9.4 KiB
Swift
281 lines
9.4 KiB
Swift
import Foundation
|
|
|
|
// MARK: - API Error
|
|
enum APIError: LocalizedError {
|
|
case invalidResponse
|
|
case httpError(statusCode: Int, message: String)
|
|
case serverError(String)
|
|
case decodingError(String)
|
|
case unauthorized
|
|
case noData
|
|
case invalidURL
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .invalidResponse: return "Invalid response from server"
|
|
case .httpError(let code, let msg): return "HTTP \(code): \(msg)"
|
|
case .serverError(let msg): return msg
|
|
case .decodingError(let msg): return "Failed to decode: \(msg)"
|
|
case .unauthorized: return "Please log in to continue"
|
|
case .noData: return "No data received"
|
|
case .invalidURL: return "Invalid URL"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - API Service
|
|
actor APIService {
|
|
static let shared = APIService()
|
|
|
|
static let baseURL = "https://food.payfrit.com/api"
|
|
|
|
private let session: URLSession
|
|
private var userToken: String?
|
|
|
|
init() {
|
|
let config = URLSessionConfiguration.default
|
|
config.timeoutIntervalForRequest = 30
|
|
config.timeoutIntervalForResource = 60
|
|
self.session = URLSession(configuration: config)
|
|
}
|
|
|
|
// MARK: - Token Management
|
|
func setToken(_ token: String) {
|
|
self.userToken = token
|
|
}
|
|
|
|
func clearToken() {
|
|
self.userToken = nil
|
|
}
|
|
|
|
// MARK: - HTTP Methods
|
|
private func request(_ endpoint: String, method: String = "GET", body: [String: Any]? = nil, includeAuth: Bool = true) async throws -> [String: Any] {
|
|
guard let url = URL(string: "\(Self.baseURL)/\(endpoint)") else {
|
|
throw APIError.invalidURL
|
|
}
|
|
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = method
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
|
|
if includeAuth, let token = userToken {
|
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
|
}
|
|
|
|
if let body = body {
|
|
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
|
}
|
|
|
|
let (data, response) = try await session.data(for: request)
|
|
|
|
guard let httpResponse = response as? HTTPURLResponse else {
|
|
throw APIError.invalidResponse
|
|
}
|
|
|
|
if httpResponse.statusCode == 401 {
|
|
throw APIError.unauthorized
|
|
}
|
|
|
|
guard (200...299).contains(httpResponse.statusCode) else {
|
|
let message = String(data: data, encoding: .utf8) ?? "Unknown error"
|
|
throw APIError.httpError(statusCode: httpResponse.statusCode, message: message)
|
|
}
|
|
|
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
throw APIError.decodingError("Invalid JSON response")
|
|
}
|
|
|
|
// Check for API-level errors
|
|
if let ok = json["ok"] as? Bool, !ok {
|
|
let message = json["error"] as? String ?? json["message"] as? String ?? "Unknown error"
|
|
throw APIError.serverError(message)
|
|
}
|
|
|
|
return json
|
|
}
|
|
|
|
private func get(_ endpoint: String, includeAuth: Bool = true) async throws -> [String: Any] {
|
|
return try await request(endpoint, method: "GET", includeAuth: includeAuth)
|
|
}
|
|
|
|
private func post(_ endpoint: String, body: [String: Any], includeAuth: Bool = true) async throws -> [String: Any] {
|
|
return try await request(endpoint, method: "POST", body: body, includeAuth: includeAuth)
|
|
}
|
|
|
|
private func delete(_ endpoint: String) async throws -> [String: Any] {
|
|
return try await request(endpoint, method: "DELETE")
|
|
}
|
|
|
|
// MARK: - Auth Endpoints
|
|
func login(email: String, password: String) async throws -> (UserProfile, String) {
|
|
let json = try await post("user/login.php", body: ["email": email, "password": password], includeAuth: false)
|
|
|
|
guard let token = json["token"] as? String,
|
|
let userData = json["user"] as? [String: Any] else {
|
|
throw APIError.decodingError("Missing token or user data")
|
|
}
|
|
|
|
let profile = UserProfile(json: userData)
|
|
return (profile, token)
|
|
}
|
|
|
|
func register(email: String, password: String, name: String, zipCode: String) async throws -> (UserProfile, String) {
|
|
let body: [String: Any] = [
|
|
"email": email,
|
|
"password": password,
|
|
"name": name,
|
|
"zipCode": zipCode
|
|
]
|
|
let json = try await post("user/register.php", body: body, includeAuth: false)
|
|
|
|
guard let token = json["token"] as? String,
|
|
let userData = json["user"] as? [String: Any] else {
|
|
throw APIError.decodingError("Missing token or user data")
|
|
}
|
|
|
|
let profile = UserProfile(json: userData)
|
|
return (profile, token)
|
|
}
|
|
|
|
func logout() async throws {
|
|
_ = try await post("user/account.php", body: ["action": "logout"])
|
|
}
|
|
|
|
func deleteAccount() async throws {
|
|
_ = try await post("user/account.php", body: ["action": "delete"])
|
|
}
|
|
|
|
// MARK: - User Endpoints
|
|
func getProfile() async throws -> UserProfile {
|
|
let json = try await get("user/account.php")
|
|
|
|
guard let userData = json["user"] as? [String: Any] else {
|
|
throw APIError.decodingError("Missing user data")
|
|
}
|
|
|
|
return UserProfile(json: userData)
|
|
}
|
|
|
|
func exportData() async throws -> URL {
|
|
let json = try await post("user/account.php", body: ["action": "export"])
|
|
|
|
guard let urlString = json["downloadUrl"] as? String,
|
|
let url = URL(string: urlString) else {
|
|
throw APIError.decodingError("Missing download URL")
|
|
}
|
|
|
|
return url
|
|
}
|
|
|
|
// MARK: - Product Endpoints
|
|
func lookupProduct(barcode: String) async throws -> Product {
|
|
let json = try await get("scan.php?barcode=\(barcode)", includeAuth: false)
|
|
|
|
// API returns product data at root level or nested under "product"
|
|
let productData = (json["product"] as? [String: Any]) ?? json
|
|
return Product(json: productData)
|
|
}
|
|
|
|
func getProduct(id: Int) async throws -> Product {
|
|
let json = try await get("scan.php?id=\(id)", includeAuth: false)
|
|
|
|
// API returns product data at root level or nested under "product"
|
|
let productData = (json["product"] as? [String: Any]) ?? json
|
|
return Product(json: productData)
|
|
}
|
|
|
|
func getAlternatives(productId: Int, sort: String? = nil, filters: [String]? = nil) async throws -> [Alternative] {
|
|
var endpoint = "alternatives.php?productId=\(productId)"
|
|
|
|
if let sort = sort {
|
|
endpoint += "&sort=\(sort)"
|
|
}
|
|
if let filters = filters, !filters.isEmpty {
|
|
endpoint += "&filters=\(filters.joined(separator: ","))"
|
|
}
|
|
|
|
let json = try await get(endpoint, includeAuth: false)
|
|
|
|
guard let alternativesData = json["alternatives"] as? [[String: Any]] else {
|
|
return []
|
|
}
|
|
|
|
return alternativesData.map { Alternative(json: $0) }
|
|
}
|
|
|
|
// MARK: - Favorites Endpoints
|
|
func getFavorites() async throws -> [Product] {
|
|
let json = try await get("user/favorites.php")
|
|
|
|
guard let productsData = json["products"] as? [[String: Any]] else {
|
|
return []
|
|
}
|
|
|
|
return productsData.map { Product(json: $0) }
|
|
}
|
|
|
|
func addFavorite(productId: Int) async throws {
|
|
_ = try await post("user/favorites.php", body: ["action": "add", "productId": productId])
|
|
}
|
|
|
|
func removeFavorite(productId: Int) async throws {
|
|
_ = try await post("user/favorites.php", body: ["action": "remove", "productId": productId])
|
|
}
|
|
|
|
// MARK: - History Endpoints
|
|
func getHistory() async throws -> [ScanHistoryItem] {
|
|
let json = try await get("user/scans.php")
|
|
|
|
guard let itemsData = json["items"] as? [[String: Any]] else {
|
|
return []
|
|
}
|
|
|
|
return itemsData.map { ScanHistoryItem(json: $0) }
|
|
}
|
|
|
|
func addToHistory(productId: Int) async throws {
|
|
_ = try await post("user/scans.php", body: ["productId": productId])
|
|
}
|
|
}
|
|
|
|
// MARK: - JSON Helpers
|
|
enum JSON {
|
|
static func parseInt(_ value: Any?) -> Int {
|
|
if let i = value as? Int { return i }
|
|
if let s = value as? String, let i = Int(s) { return i }
|
|
if let d = value as? Double { return Int(d) }
|
|
return 0
|
|
}
|
|
|
|
static func parseDouble(_ value: Any?) -> Double {
|
|
if let d = value as? Double { return d }
|
|
if let i = value as? Int { return Double(i) }
|
|
if let s = value as? String, let d = Double(s) { return d }
|
|
return 0
|
|
}
|
|
|
|
static func parseString(_ value: Any?) -> String {
|
|
if let s = value as? String { return s }
|
|
if let i = value as? Int { return String(i) }
|
|
if let d = value as? Double { return String(d) }
|
|
return ""
|
|
}
|
|
|
|
static func parseBool(_ value: Any?) -> Bool {
|
|
if let b = value as? Bool { return b }
|
|
if let i = value as? Int { return i != 0 }
|
|
if let s = value as? String { return s.lowercased() == "true" || s == "1" }
|
|
return false
|
|
}
|
|
|
|
static func parseOptionalString(_ value: Any?) -> String? {
|
|
if let s = value as? String, !s.isEmpty { return s }
|
|
return nil
|
|
}
|
|
|
|
static func parseStringArray(_ value: Any?) -> [String] {
|
|
if let arr = value as? [String] { return arr }
|
|
if let s = value as? String { return s.components(separatedBy: ",").map { $0.trimmingCharacters(in: .whitespaces) } }
|
|
return []
|
|
}
|
|
}
|