import { identifyFood, identifyFoodStreaming } from '@/add-log/food-detection/foodDetectionService'; import { endSpan, forkTrace, startSpan } from '@/add-log/telemetry'; import { useHaptics } from '@/aesthetics/useHaptics'; import { useStrings } from '@/common/hooks/useStrings'; import { resizeAndEncodeImage } from '@/common/utils/image'; import type { DetectionPhase, StagedFoodLog } from '@/types/foodlog'; import * as Crypto from 'expo-crypto'; import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; import { useCallback, useRef } from 'react'; import { InteractionManager } from 'react-native'; const STREAM_THROTTLE_MS = 250; type UseFoodIdentificationParams = { setStagedFoodLogs: Dispatch>; mountedRef: MutableRefObject; }; export function useFoodIdentification({ setStagedFoodLogs, mountedRef, }: UseFoodIdentificationParams) { const strings = useStrings(); const { impact } = useHaptics(); const abortControllers = useRef>(new Map()); const updatePhase = useCallback( (logId: string, phase: DetectionPhase) => { setStagedFoodLogs(prev => prev.map(log => (log.id === logId ? { ...log, detectionPhase: phase } : log)) ); }, [setStagedFoodLogs] ); const runStream = useCallback( async (logId: string, photoUri: string) => { const controller = new AbortController(); abortControllers.current.set(logId, controller); try { updatePhase(logId, 'capturing'); InteractionManager.runAfterInteractions(async () => { if (!mountedRef.current || controller.signal.aborted) { abortControllers.current.delete(logId); return; } const phaseTimers: ReturnType[] = []; let streamStarted = false; try { updatePhase(logId, 'compressing'); startSpan(logId, 'image-encode'); const base64 = await resizeAndEncodeImage(photoUri); endSpan(logId, 'image-encode'); if (!mountedRef.current || controller.signal.aborted) return; setStagedFoodLogs(prev => prev.map(log => (log.id === logId ? { ...log, photoBase64: base64 } : log)) ); updatePhase(logId, 'connecting'); startSpan(logId, 'food-detection-ai'); const schedulePhase = (phase: DetectionPhase, delayMs: number) => { phaseTimers.push( setTimeout(() => { if (!streamStarted && mountedRef.current && !controller.signal.aborted) { updatePhase(logId, phase); } }, delayMs) ); }; schedulePhase('uploading', 800); schedulePhase('identifying', 4500); schedulePhase('ingredients', 6500); let firstItemEmitted = false; let lastUpdateTime = 0; const items = await identifyFoodStreaming({ base64, signal: controller.signal, onUpdate: update => { if (!mountedRef.current || controller.signal.aborted) return; const primary = update.completedItems[0] ?? update.partialItem; if (!primary?.foodName) return; if (!firstItemEmitted) { firstItemEmitted = true; streamStarted = true; phaseTimers.forEach(clearTimeout); updatePhase(logId, 'streaming'); } const now = Date.now(); if (now - lastUpdateTime < STREAM_THROTTLE_MS) return; lastUpdateTime = now; setStagedFoodLogs(prev => prev.map(log => { if (log.id !== logId) return log; return { ...log, detectionPhase: 'streaming' as DetectionPhase, foodName: primary.foodName!, aiHPPredIngredients: primary.hpPredIngredients ?? log.aiHPPredIngredients, aiHRPredIngredients: primary.hrPredIngredients ?? log.aiHRPredIngredients, boundingBox: primary.boundingBox ?? log.boundingBox, }; }) ); }, }); endSpan(logId, 'food-detection-ai'); await endSpan(logId, 'food-detection'); if (!mountedRef.current || controller.signal.aborted) return; if (items.length === 0) { setStagedFoodLogs(prev => prev.map(log => log.id === logId ? { ...log, detectionPhase: 'complete', foodName: '' } : log ) ); impact(); return; } if (items.length > 1) { const newLogIds = items.slice(1).map(() => Crypto.randomUUID()); newLogIds.forEach(newId => forkTrace(logId, newId)); setStagedFoodLogs(prev => { if (!prev.some(l => l.id === logId)) return prev; const filtered = prev.filter(l => l.id !== logId); const newLogs: StagedFoodLog[] = items.map((item, i) => ({ id: i === 0 ? logId : newLogIds[i - 1], foodName: item.foodName, photoUri, photoBase64: base64, fileCreationTime: Date.now(), detectionPhase: 'complete' as DetectionPhase, boundingBox: item.boundingBox, selectedCandidateIndex: 0, foodCandidates: [ { foodName: item.foodName, hpPredIngredients: item.hpPredIngredients, hrPredIngredients: item.hrPredIngredients, }, ], aiHPPredIngredients: item.hpPredIngredients, aiHRPredIngredients: item.hrPredIngredients, userEnabledIngredients: [], userDisabledIngredients: [], })); return [...newLogs, ...filtered]; }); } else { const item = items[0]; setStagedFoodLogs(prev => prev.map(log => log.id === logId ? { ...log, detectionPhase: 'complete', foodName: item.foodName, boundingBox: item.boundingBox, selectedCandidateIndex: 0, foodCandidates: [ { foodName: item.foodName, hpPredIngredients: item.hpPredIngredients, hrPredIngredients: item.hrPredIngredients, }, ], aiHPPredIngredients: item.hpPredIngredients, aiHRPredIngredients: item.hrPredIngredients, } : log ) ); } impact(); fetchCandidatesInBackground(logId, base64); } catch (e) { endSpan(logId, 'image-encode', e); endSpan(logId, 'food-detection-ai', e); await endSpan(logId, 'food-detection', e); console.error('Food identification failed:', e); if (!mountedRef.current) return; updatePhase(logId, 'error'); } finally { phaseTimers.forEach(clearTimeout); abortControllers.current.delete(logId); } }); } catch { abortControllers.current.delete(logId); } }, [mountedRef, setStagedFoodLogs, strings, impact, updatePhase] ); const fetchCandidatesInBackground = useCallback( async (logId: string, base64: string) => { try { const { result } = await identifyFood({ base64 }); if (!mountedRef.current) return; if (!result?.candidates.length) return; setStagedFoodLogs(prev => prev.map(log => { if (log.id !== logId) return log; const existing = log.foodCandidates ?? []; const existingNames = new Set(existing.map(c => c.foodName)); const newCandidates = result.candidates.filter(c => !existingNames.has(c.foodName)); if (newCandidates.length === 0) return log; return { ...log, foodCandidates: [...existing, ...newCandidates].slice(0, 3) }; }) ); } catch { /* ignore */ } }, [mountedRef, setStagedFoodLogs] ); const identifyFromImage = useCallback( (logId: string, photoUri: string) => { runStream(logId, photoUri); }, [runStream] ); const cancelStream = useCallback((logId: string) => { const controller = abortControllers.current.get(logId); if (controller) { controller.abort(); abortControllers.current.delete(logId); } }, []); const fetchIngredientsForLog = useCallback( async (logId: string, foodName: string) => { try { const { result } = await identifyFood({ text: foodName }); await endSpan(logId, 'ingredient-detection-ai'); if (!mountedRef.current) return; const primaryCandidate = result?.candidates[0]; setStagedFoodLogs(prev => prev.map(log => log.id === logId ? { ...log, regeneratingIngredients: false, foodCandidates: result?.candidates.slice(0, 3), selectedCandidateIndex: 0, aiHPPredIngredients: primaryCandidate?.hpPredIngredients ?? [], aiHRPredIngredients: primaryCandidate?.hrPredIngredients ?? [], } : log ) ); } catch (e) { await endSpan(logId, 'ingredient-detection-ai', e); console.error('Failed to fetch ingredients:', e); if (!mountedRef.current) return; setStagedFoodLogs(prev => prev.map(log => (log.id === logId ? { ...log, regeneratingIngredients: false } : log)) ); } }, [mountedRef, setStagedFoodLogs] ); const handleFoodCandidateSwitch = useCallback( async (logId: string, candidateIndex: number) => { impact(); let logSnapshot: StagedFoodLog | undefined; let needsFetch = false; setStagedFoodLogs(prev => { const log = prev.find(l => l.id === logId); if (!log || !log.foodCandidates || candidateIndex === log.selectedCandidateIndex) { return prev; } const selectedCandidate = log.foodCandidates[candidateIndex]; if (!selectedCandidate) return prev; const hasIngredients = (selectedCandidate.hpPredIngredients && selectedCandidate.hpPredIngredients.length > 0) || (selectedCandidate.hrPredIngredients && selectedCandidate.hrPredIngredients.length > 0); if (hasIngredients) { return prev.map(l => l.id === logId ? { ...l, selectedCandidateIndex: candidateIndex, foodName: selectedCandidate.foodName, aiHPPredIngredients: selectedCandidate.hpPredIngredients ?? [], aiHRPredIngredients: selectedCandidate.hrPredIngredients ?? [], userEnabledIngredients: [], userDisabledIngredients: [], } : l ); } logSnapshot = log; needsFetch = true; return prev.map(l => l.id === logId ? { ...l, selectedCandidateIndex: candidateIndex, foodName: selectedCandidate.foodName, regeneratingIngredients: true, userEnabledIngredients: [], userDisabledIngredients: [], } : l ); }); if (!needsFetch || !logSnapshot) return; const selectedCandidate = logSnapshot.foodCandidates![candidateIndex]; try { startSpan(logId, 'candidate-switch-ai'); const { result } = await identifyFood({ base64: logSnapshot.photoBase64, foodName: selectedCandidate.foodName, }); endSpan(logId, 'candidate-switch-ai'); if (!mountedRef.current) return; const candidate = result?.candidates[0]; const confidentIngredients = candidate?.hpPredIngredients ?? []; const maybeIngredients = candidate?.hrPredIngredients ?? []; setStagedFoodLogs(prev => prev.map(l => { if (l.id !== logId) return l; const updatedCandidates = l.foodCandidates?.map((c, i) => i === candidateIndex ? { ...c, hpPredIngredients: confidentIngredients, hrPredIngredients: maybeIngredients, } : c ); return { ...l, regeneratingIngredients: false, foodCandidates: updatedCandidates, aiHPPredIngredients: confidentIngredients, aiHRPredIngredients: maybeIngredients, }; }) ); } catch (e) { endSpan(logId, 'candidate-switch-ai', e); if (!mountedRef.current) return; setStagedFoodLogs(prev => prev.map(l => (l.id === logId ? { ...l, regeneratingIngredients: false } : l)) ); } }, [mountedRef, setStagedFoodLogs, impact] ); return { identifyFromImage, fetchIngredientsForLog, handleFoodCandidateSwitch, cancelStream, }; }