@@ -5,25 +5,48 @@ import Observation
55import SwiftUI
66
77extension BarcodeScanner {
8+ /// Scan mode for the barcode scanner
9+ enum ScanMode : String , CaseIterable {
10+ case barcode = " Barcode "
11+ case nutritionLabel = " Nutrition Label "
12+
13+ var icon : String {
14+ switch self {
15+ case . barcode: return " barcode.viewfinder "
16+ case . nutritionLabel: return " doc.text.viewfinder "
17+ }
18+ }
19+
20+ var localizedName : String {
21+ switch self {
22+ case . barcode: return String ( localized: " Barcode " )
23+ case . nutritionLabel: return String ( localized: " Nutrition Label " )
24+ }
25+ }
26+ }
27+
828 /// Represents a scanned product with user-entered amount.
929 struct ScannedProductItem : Identifiable , Equatable {
1030 let id : UUID
1131 let product : OpenFoodFactsProduct
1232 var amount : Double
1333 var isMlInput : Bool
34+ let isManualEntry : Bool
1435
15- init ( product: OpenFoodFactsProduct , amount: Double = 0 , isMlInput: Bool = false ) {
36+ init ( product: OpenFoodFactsProduct , amount: Double = 0 , isMlInput: Bool = false , isManualEntry : Bool = false ) {
1637 id = UUID ( )
1738 self . product = product
1839 self . amount = amount
1940 self . isMlInput = isMlInput
41+ self . isManualEntry = isManualEntry
2042 }
2143
2244 static func == ( lhs: ScannedProductItem , rhs: ScannedProductItem ) -> Bool {
2345 lhs. id == rhs. id &&
2446 lhs. product == rhs. product &&
2547 lhs. amount == rhs. amount &&
26- lhs. isMlInput == rhs. isMlInput
48+ lhs. isMlInput == rhs. isMlInput &&
49+ lhs. isManualEntry == rhs. isManualEntry
2750 }
2851 }
2952
@@ -36,7 +59,25 @@ extension BarcodeScanner {
3659 var errorMessage : String ?
3760 var scannedProducts : [ ScannedProductItem ] = [ ]
3861
62+ // Scan mode
63+ var scanMode : ScanMode = . barcode
64+
65+ // Nutrition label scanning
66+ var isCapturingPhoto = false
67+ var capturedImage : UIImage ?
68+ var scannedNutritionData : NutritionLabelScanner . NutritionData ?
69+ var isProcessingLabel = false
70+ var showNutritionEditor = false
71+ var editableNutritionName : String = " "
72+ var showCameraPicker = false
73+
74+ // AI Model for nutrition label extraction
75+ let modelManager = NutritionModelManager ( )
76+ var showModelFilePicker = false
77+ var useAIModel = true // Toggle between AI model and regex-based extraction
78+
3979 private let client = OpenFoodFactsClient ( )
80+ private let nutritionScanner = NutritionLabelScanner ( )
4081 private var lastScanTime : Date ?
4182 private let scanCooldownSeconds : TimeInterval = 1.0
4283
@@ -46,6 +87,15 @@ extension BarcodeScanner {
4687
4788 func handleAppear( ) {
4889 refreshCameraStatus ( )
90+ modelManager. checkModelStatus ( )
91+
92+ // Auto-load model if downloaded but not ready
93+ if case . downloaded = modelManager. state {
94+ Task {
95+ try ? await modelManager. loadModel ( )
96+ }
97+ }
98+
4999 switch cameraStatus {
50100 case . notDetermined:
51101 requestCameraAccess ( )
@@ -202,6 +252,162 @@ extension BarcodeScanner {
202252 guard let url = URL ( string: UIApplication . openSettingsURLString) else { return }
203253 UIApplication . shared. open ( url)
204254 }
255+
256+ // MARK: - Nutrition Label Scanning
257+
258+ /// Switches the scan mode
259+ func switchScanMode( to mode: ScanMode ) {
260+ scanMode = mode
261+ errorMessage = nil
262+
263+ // Reset nutrition label specific state when switching away
264+ if mode == . barcode {
265+ capturedImage = nil
266+ scannedNutritionData = nil
267+ showNutritionEditor = false
268+ editableNutritionName = " "
269+ }
270+ }
271+
272+ /// Captures a photo for nutrition label scanning
273+ func capturePhoto( ) {
274+ print ( " 📸 [StateModel] capturePhoto() triggered, setting isCapturingPhoto = true " )
275+ isCapturingPhoto = true
276+ }
277+
278+ /// Called when a photo is captured from the camera
279+ func didCapturePhoto( _ image: UIImage ) {
280+ print ( " 📷 [StateModel] Photo captured! " )
281+ print ( " 📷 [StateModel] Image dimensions: \( image. size. width) x \( image. size. height) " )
282+ isCapturingPhoto = false
283+ capturedImage = image
284+ isScanning = false
285+ processNutritionLabel ( image)
286+ }
287+
288+ /// Processes a captured image to extract nutrition data
289+ private func processNutritionLabel( _ image: UIImage ) {
290+ print ( " 📸 [StateModel] Processing nutrition label... " )
291+ print ( " 📸 [StateModel] Image size: \( image. size) " )
292+ print ( " 📸 [StateModel] useAIModel: \( useAIModel) , modelReady: \( modelManager. isReady) " )
293+
294+ isProcessingLabel = true
295+ errorMessage = nil
296+
297+ Task {
298+ do {
299+ let data : NutritionLabelScanner . NutritionData
300+
301+ // Use AI model if available and enabled, otherwise fall back to regex-based OCR
302+ if useAIModel, modelManager. isReady {
303+ print ( " 🤖 [StateModel] Using AI model for extraction... " )
304+ data = try await nutritionScanner. scanWithAIModel ( from: image, modelManager: modelManager)
305+ } else {
306+ print ( " 📝 [StateModel] Using regex-based extraction... " )
307+ // Fall back to regex-based OCR extraction
308+ data = try await nutritionScanner. scanNutritionLabel ( from: image)
309+ }
310+
311+ print ( " ✅ [StateModel] Extraction complete - hasData: \( data. hasAnyData) " )
312+ print (
313+ " ✅ [StateModel] Calories: \( String ( describing: data. calories) ) , Carbs: \( String ( describing: data. carbohydrates) ) "
314+ )
315+
316+ await MainActor . run {
317+ self . scannedNutritionData = data
318+ self . isProcessingLabel = false
319+
320+ if data. hasAnyData {
321+ print ( " ✅ [StateModel] Showing nutrition editor " )
322+ self . showNutritionEditor = true
323+ self . editableNutritionName = String ( localized: " Scanned Label " )
324+ } else {
325+ print ( " ⚠️ [StateModel] No nutrition data found " )
326+ self . errorMessage = String ( localized: " No nutrition information found. Try taking a clearer photo. " )
327+ }
328+ }
329+ } catch {
330+ print ( " ❌ [StateModel] Extraction failed: \( error) " )
331+ print ( " ❌ [StateModel] Error: \( error. localizedDescription) " )
332+ await MainActor . run {
333+ self . isProcessingLabel = false
334+ self . errorMessage = ( error as? LocalizedError ) ? . errorDescription ?? error. localizedDescription
335+ }
336+ }
337+ }
338+ }
339+
340+ // MARK: - Model Management
341+
342+ /// Loads the AI model if downloaded
343+ func loadModelIfNeeded( ) {
344+ guard modelManager. state == . downloaded else { return }
345+
346+ Task {
347+ try ? await modelManager. loadModel ( )
348+ }
349+ }
350+
351+ /// Deletes the downloaded model
352+ func deleteModel( ) {
353+ modelManager. deleteModel ( )
354+ }
355+
356+ /// Retakes the nutrition label photo
357+ func retakePhoto( ) {
358+ capturedImage = nil
359+ scannedNutritionData = nil
360+ showNutritionEditor = false
361+ errorMessage = nil
362+ isScanning = true
363+ }
364+
365+ /// Adds the scanned nutrition data as a product item
366+ func addScannedNutritionLabel( ) {
367+ guard let data = scannedNutritionData else { return }
368+
369+ let product = data
370+ . toProduct ( name: editableNutritionName. isEmpty ? String ( localized: " Scanned Label " ) : editableNutritionName)
371+ let item = ScannedProductItem (
372+ product: product,
373+ amount: data. servingSizeGrams ?? 100 ,
374+ isMlInput: false ,
375+ isManualEntry: true
376+ )
377+
378+ scannedProducts. append ( item)
379+
380+ // Reset for next scan
381+ capturedImage = nil
382+ scannedNutritionData = nil
383+ showNutritionEditor = false
384+ editableNutritionName = " "
385+ isScanning = true
386+ }
387+
388+ /// Updates the scanned nutrition data with edited values
389+ func updateScannedNutritionData(
390+ calories: Double ? ,
391+ carbohydrates: Double ? ,
392+ sugars: Double ? ,
393+ fat: Double ? ,
394+ protein: Double ? ,
395+ fiber: Double ? ,
396+ servingSizeGrams: Double ?
397+ ) {
398+ scannedNutritionData = NutritionLabelScanner . NutritionData (
399+ calories: calories,
400+ carbohydrates: carbohydrates,
401+ sugars: sugars,
402+ fat: fat,
403+ saturatedFat: scannedNutritionData? . saturatedFat,
404+ protein: protein,
405+ fiber: fiber,
406+ sodium: scannedNutritionData? . sodium,
407+ servingSize: scannedNutritionData? . servingSize,
408+ servingSizeGrams: servingSizeGrams
409+ )
410+ }
205411 }
206412}
207413
0 commit comments