// // ScannerViewModel.swift // SousChefAI // // ViewModel for the scanner view with real-time ingredient detection // import Foundation import SwiftUI import CoreVideo import AVFoundation import Combine @MainActor final class ScannerViewModel: ObservableObject { @Published var detectedIngredients: [Ingredient] = [] @Published var isScanning = false @Published var error: Error? @Published var scanProgress: String = "Ready to scan" /// The most recently detected new ingredient (for banner display) @Published var latestNewIngredient: Ingredient? private let visionService: VisionService private let cameraManager: CameraManager private var scanTask: Task? /// Callback when a new ingredient is detected (not a duplicate) var onNewIngredientDetected: ((Ingredient) -> Void)? nonisolated init(cameraManager: CameraManager = CameraManager()) { print("📱 ScannerViewModel.init() - Creating ViewModel at \(Date())") // Select vision service based on configuration let visionService: VisionService = switch AppConfig.scanningMode { case .geminiVision: GeminiVisionService() case .arKit: ARVisionService() } print("📱 ScannerViewModel.init() - Using \(AppConfig.scanningMode.rawValue) scanning mode") self.visionService = visionService self.cameraManager = cameraManager } // MARK: - Camera Management func setupCamera() async { print("📱 ScannerViewModel.setupCamera() - STARTED at \(Date())") do { try await cameraManager.setupSession() print("📱 ScannerViewModel.setupCamera() - ✅ SUCCESS at \(Date())") } catch { print("📱 ScannerViewModel.setupCamera() - ❌ ERROR: \(error)") self.error = error } } func startCamera() { cameraManager.startSession() } func stopCamera() { cameraManager.stopSession() } func getPreviewLayer() -> AVCaptureVideoPreviewLayer? { print("📱 ScannerViewModel.getPreviewLayer() - ⚠️ REQUESTING preview layer at \(Date())") return cameraManager.previewLayer() } // MARK: - Scanning func startScanning() { guard !isScanning else { return } isScanning = true scanProgress = "Scanning ingredients..." print("📱 ScannerViewModel.startScanning() - Started with \(AppConfig.scanningMode.rawValue) mode") scanTask = Task { let startTime = Date() do { let stream = cameraManager.frameStream() // For Gemini mode, we use real-time detection with callbacks if AppConfig.scanningMode == .geminiVision { // Process frames continuously until stopped or timeout var lastProcessTime = Date() var currentSecondFrames: [(buffer: CVPixelBuffer, timestamp: Date)] = [] for await frame in stream { guard !Task.isCancelled else { break } // Check timeout if Date().timeIntervalSince(startTime) >= AppConfig.maxScanDuration { print("📱 ScannerViewModel: Max scan duration reached") break } currentSecondFrames.append((buffer: frame, timestamp: Date())) // Process every second let now = Date() if now.timeIntervalSince(lastProcessTime) >= AppConfig.geminiFrameInterval { // Pick the frame from the middle of the batch (reasonable approximation) if let bestFrame = currentSecondFrames[safe: currentSecondFrames.count / 2]?.buffer { do { let previousCount = detectedIngredients.count let ingredients = try await visionService.detectIngredients(from: bestFrame) // Find new ingredients before merging let newIngredients = findNewIngredients(ingredients) // Merge with existing updateDetectedIngredients(ingredients, mergeMode: true) // Notify about new ingredients for newIngredient in newIngredients { print("🆕 New ingredient detected: \(newIngredient.name)") latestNewIngredient = newIngredient onNewIngredientDetected?(newIngredient) } scanProgress = "Found \(detectedIngredients.count) items..." } catch { print("⚠️ Frame analysis error: \(error)") // Continue scanning on errors } } currentSecondFrames.removeAll() lastProcessTime = now } // Stop if we have enough ingredients if detectedIngredients.count >= AppConfig.maxIngredientsPerScan { break } } } else { // AR mode: use batch detection let ingredients = try await visionService.detectIngredients(from: stream) updateDetectedIngredients(ingredients) } scanProgress = "Scan complete! Found \(detectedIngredients.count) ingredients" } catch { self.error = error scanProgress = "Scan failed: \(error.localizedDescription)" } isScanning = false } } func stopScanning() { scanTask?.cancel() scanTask = nil isScanning = false scanProgress = detectedIngredients.isEmpty ? "Ready to scan" : "Scan captured" } // MARK: - Real-time Detection Mode func startRealTimeDetection() { guard !isScanning else { return } isScanning = true scanProgress = "Detecting in real-time..." scanTask = Task { let stream = cameraManager.frameStream() for await frame in stream { guard !Task.isCancelled else { break } do { // Process individual frames let ingredients = try await visionService.detectIngredients(from: frame) updateDetectedIngredients(ingredients, mergeMode: true) scanProgress = "Detected \(detectedIngredients.count) items" } catch { // Continue on errors in real-time mode continue } // Throttle to avoid overwhelming the API try? await Task.sleep(for: .seconds(1)) } isScanning = false } } // MARK: - Ingredient Management /// Finds ingredients that are truly new (not already in our list) private func findNewIngredients(_ newIngredients: [Ingredient]) -> [Ingredient] { return newIngredients.filter { newIngredient in !detectedIngredients.contains { existing in isSimilarIngredient(existing.name, newIngredient.name) } } } /// Checks if two ingredient names refer to the same item private func isSimilarIngredient(_ name1: String, _ name2: String) -> Bool { let n1 = name1.lowercased() let n2 = name2.lowercased() // Exact match if n1 == n2 { return true } // One contains the other if n1.contains(n2) || n2.contains(n1) { return true } return false } private func updateDetectedIngredients(_ newIngredients: [Ingredient], mergeMode: Bool = false) { if mergeMode { // Merge with existing ingredients, keeping higher confidence and max quantity var merged = detectedIngredients.reduce(into: [String: Ingredient]()) { dict, ingredient in dict[ingredient.name.lowercased()] = ingredient } for ingredient in newIngredients { let normalizedName = ingredient.name.lowercased() // Check for similar existing items let similarKey = merged.keys.first { existingKey in isSimilarIngredient(existingKey, normalizedName) } if let key = similarKey, let existing = merged[key] { // Merge: take max quantity, higher confidence let mergedQuantity = mergeQuantities(existing.estimatedQuantity, ingredient.estimatedQuantity) let mergedConfidence = max(existing.confidence, ingredient.confidence) merged[key] = Ingredient( id: existing.id, name: existing.name, estimatedQuantity: mergedQuantity, confidence: mergedConfidence, guesses: existing.guesses.isEmpty ? ingredient.guesses : existing.guesses ) } else { merged[normalizedName] = ingredient } } detectedIngredients = Array(merged.values).sorted { $0.confidence > $1.confidence } } else { detectedIngredients = newIngredients } } /// Merges two quantity strings, taking the maximum numeric value private func mergeQuantities(_ q1: String, _ q2: String) -> String { let num1 = extractNumber(from: q1) ?? 0 let num2 = extractNumber(from: q2) ?? 0 return num1 >= num2 ? q1 : q2 } private func extractNumber(from string: String) -> Double? { let pattern = #"[\d.]+"# guard let regex = try? NSRegularExpression(pattern: pattern), let match = regex.firstMatch(in: string, range: NSRange(string.startIndex..., in: string)), let range = Range(match.range, in: string) else { return nil } return Double(string[range]) } func addIngredient(_ ingredient: Ingredient) { if !detectedIngredients.contains(where: { $0.id == ingredient.id }) { detectedIngredients.append(ingredient) } } func removeIngredient(_ ingredient: Ingredient) { detectedIngredients.removeAll { $0.id == ingredient.id } } func updateIngredient(_ ingredient: Ingredient) { if let index = detectedIngredients.firstIndex(where: { $0.id == ingredient.id }) { detectedIngredients[index] = ingredient } } // MARK: - Manual Entry func addManualIngredient(name: String, quantity: String) { let ingredient = Ingredient( name: name, estimatedQuantity: quantity, confidence: 1.0 ) detectedIngredients.append(ingredient) } // MARK: - Cleanup func cleanup() async { print("📱 ScannerViewModel.cleanup() - Starting cleanup") stopScanning() await cameraManager.cleanup() print("📱 ScannerViewModel.cleanup() - ✅ Cleanup complete") } // MARK: - Local Persistence /// Saves ingredients locally using UserDefaults /// TODO: Migrate to FirestoreRepository when Firebase is configured /// To migrate: Replace this method with a call to FirestoreRepository.saveIngredients() func saveIngredientsLocally() { do { let data = try JSONEncoder().encode(detectedIngredients) UserDefaults.standard.set(data, forKey: "savedIngredients") print("💾 Saved \(detectedIngredients.count) ingredients locally") } catch { print("❌ Failed to save ingredients: \(error)") } } /// Loads ingredients from local storage /// TODO: Migrate to FirestoreRepository when Firebase is configured /// To migrate: Replace this method with a call to FirestoreRepository.loadIngredients() func loadIngredientsLocally() { guard let data = UserDefaults.standard.data(forKey: "savedIngredients") else { print("📂 No saved ingredients found") return } do { detectedIngredients = try JSONDecoder().decode([Ingredient].self, from: data) print("📂 Loaded \(detectedIngredients.count) ingredients from local storage") } catch { print("❌ Failed to load ingredients: \(error)") } } /// Clears all saved ingredients func clearSavedIngredients() { detectedIngredients.removeAll() UserDefaults.standard.removeObject(forKey: "savedIngredients") print("🗑️ Cleared all saved ingredients") } } // MARK: - Array Safe Subscript Extension extension Collection { /// Returns the element at the specified index if it exists, otherwise nil. subscript(safe index: Index) -> Element? { indices.contains(index) ? self[index] : nil } }