From 8d95e301b6cd2d7c0796f8f1c18e1604b6910ba6 Mon Sep 17 00:00:00 2001 From: apedley Date: Sun, 1 Mar 2026 11:00:55 -0600 Subject: [PATCH 01/14] Nutrition label scanning feature --- .../src/components/FoodForm.tsx | 1 + .../src/screens/FoodScanScreen.tsx | 182 ++++++++++++-- .../src/screens/ManualFoodEntryScreen.tsx | 3 + .../src/services/api/externalFoodSearchApi.ts | 27 +++ SparkyFitnessServer/routes/foodCrudRoutes.js | 25 ++ .../services/labelScanService.js | 228 ++++++++++++++++++ 6 files changed, 445 insertions(+), 21 deletions(-) create mode 100644 SparkyFitnessServer/services/labelScanService.js diff --git a/SparkyFitnessMobile/src/components/FoodForm.tsx b/SparkyFitnessMobile/src/components/FoodForm.tsx index 3560619a3..b0e7a89fc 100644 --- a/SparkyFitnessMobile/src/components/FoodForm.tsx +++ b/SparkyFitnessMobile/src/components/FoodForm.tsx @@ -209,6 +209,7 @@ const FoodForm: React.FC = ({ setShowMoreNutrients((prev) => !prev)} activeOpacity={0.7} + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} > {showMoreNutrients ? 'Hide extra nutrients ▴' : 'Show more nutrients ▾'} diff --git a/SparkyFitnessMobile/src/screens/FoodScanScreen.tsx b/SparkyFitnessMobile/src/screens/FoodScanScreen.tsx index 39baaf062..896517034 100644 --- a/SparkyFitnessMobile/src/screens/FoodScanScreen.tsx +++ b/SparkyFitnessMobile/src/screens/FoodScanScreen.tsx @@ -1,15 +1,21 @@ import React, { useRef, useState } from 'react'; -import { View, Text, TouchableOpacity, Platform, Button, StyleSheet, Alert, ActivityIndicator } from 'react-native'; +import { View, Text, TouchableOpacity, Platform, Button, StyleSheet, Alert, ActivityIndicator, Image } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import Icon from '../components/Icon'; +import SegmentedControl, { type Segment } from '../components/SegmentedControl'; import type { RootStackScreenProps } from '../types/navigation'; import type { FoodInfoItem } from '../types/foodInfo'; import { useCSSVariable } from 'uniwind'; import { CameraView, useCameraPermissions, type BarcodeScanningResult } from 'expo-camera'; -import { lookupBarcode } from '../services/api/externalFoodSearchApi'; +import { lookupBarcode, scanNutritionLabel } from '../services/api/externalFoodSearchApi'; type FoodScanScreenProps = RootStackScreenProps<'FoodScan'>; +const SCAN_SEGMENTS: Segment<'barcode' | 'label'>[] = [ + { key: 'barcode', label: 'Barcode' }, + { key: 'label', label: 'Nutrition Label' }, +]; + const FoodScanScreen: React.FC = ({ navigation, route }) => { const insets = useSafeAreaInsets(); const accentPrimary = String(useCSSVariable('--color-accent-primary')); @@ -18,6 +24,11 @@ const FoodScanScreen: React.FC = ({ navigation, route }) => const [loading, setLoading] = useState(false); const [flashlight, setFlashlight] = useState(false); const scanLock = useRef(false); + const [scanMode, setScanMode] = useState<'barcode' | 'label'>('barcode'); + const [notFoundBarcode, setNotFoundBarcode] = useState(null); + const [labelProcessing, setLabelProcessing] = useState(false); + const [capturedPhoto, setCapturedPhoto] = useState<{ base64: string; uri: string } | null>(null); + const cameraRef = useRef(null); const handleBarcodeScanned = async ({ data }: BarcodeScanningResult) => { if (scanLock.current) return; @@ -71,7 +82,7 @@ const FoodScanScreen: React.FC = ({ navigation, route }) => }, }); } else { - Alert.alert('Not Found', 'Product not found for this barcode.'); + setNotFoundBarcode(data); } } catch { Alert.alert('Error', 'Something went wrong looking up this barcode.'); @@ -80,6 +91,69 @@ const FoodScanScreen: React.FC = ({ navigation, route }) => } }; + const handleLabelCapture = async () => { + if (!cameraRef.current) return; + try { + const photo = await cameraRef.current.takePictureAsync({ base64: true, quality: 0.7 }); + if (!photo?.base64) { + Alert.alert('Error', 'Failed to capture photo.'); + return; + } + setCapturedPhoto({ base64: photo.base64, uri: photo.uri }); + } catch { + Alert.alert('Error', 'Failed to capture photo.'); + } + }; + + const handleUsePhoto = async () => { + if (!capturedPhoto) return; + setLabelProcessing(true); + try { + const result = await scanNutritionLabel(capturedPhoto.base64, 'image/jpeg'); + navigation.replace('ManualFoodEntry', { + date: route.params?.date, + initialFood: { + name: result.name || '', + brand: result.brand || '', + servingSize: String(result.serving_size ?? ''), + servingUnit: result.serving_unit || 'g', + calories: String(result.calories ?? ''), + protein: String(result.protein ?? ''), + carbs: String(result.carbs ?? ''), + fat: String(result.fat ?? ''), + fiber: result.fiber != null ? String(result.fiber) : '', + saturatedFat: result.saturated_fat != null ? String(result.saturated_fat) : '', + sodium: result.sodium != null ? String(result.sodium) : '', + sugars: result.sugars != null ? String(result.sugars) : '', + }, + barcode: notFoundBarcode ?? undefined, + providerType: 'label_scan', + }); + } catch { + Alert.alert('Error', 'Failed to analyze nutrition label. Please try again.'); + } finally { + setLabelProcessing(false); + } + }; + + const handleRetake = () => { + setCapturedPhoto(null); + }; + + const handleSegmentChange = (key: 'barcode' | 'label') => { + setScanMode(key); + setNotFoundBarcode(null); + setCapturedPhoto(null); + setScanned(false); + scanLock.current = false; + }; + + const handleScanLabel = () => { + setScanMode('label'); + setScanned(false); + scanLock.current = false; + }; + if (!permission) { return ; } @@ -96,45 +170,111 @@ const FoodScanScreen: React.FC = ({ navigation, route }) => return ( - + {/* Top bar: back button + flashlight */} + navigation.goBack()} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} - className="z-10" + className="bg-black/50 rounded-full p-2" > - - + setFlashlight(!flashlight)} + className="bg-black/50 rounded-full p-2" + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + > + + - {loading && ( + {(loading || labelProcessing) && ( + {labelProcessing && ( + Analyzing label... + )} + + )} + + {scanMode === 'barcode' && notFoundBarcode && !loading && ( + + No match for barcode + You can scan the nutrition label or enter it manually. + + + Scan Nutrition Label + + navigation.replace('ManualFoodEntry', { + date: route.params?.date, + barcode: notFoundBarcode, + })} + className="flex-1 bg-white/20 py-3 rounded-lg items-center" + > + Enter Manually + + )} - {scanned && !loading && ( - -