Files
SousChefAI/SousChefAI/ViewModels/ScannerViewModel.swift
2026-04-04 15:22:57 -05:00

365 lines
14 KiB
Swift

//
// 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<Void, Never>?
/// 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
}
}