365 lines
14 KiB
Swift
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
|
|
}
|
|
}
|