import { endSpan, startSpan, startTrace } from '@/add-log/telemetry'; import { useHaptics } from '@/aesthetics/useHaptics'; import { STORAGE_KEYS } from '@/common/constants/storageKeys'; import { useStrings } from '@/common/hooks/useStrings'; import { cropImage, persistImage } from '@/common/utils/image'; import type { CameraMode } from '@/types/foodlog'; import AsyncStorage from '@react-native-async-storage/async-storage'; import * as Crypto from 'expo-crypto'; import type { MutableRefObject } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Dimensions, Keyboard } from 'react-native'; import { Gesture } from 'react-native-gesture-handler'; import { runOnJS, useSharedValue } from 'react-native-reanimated'; import type { Camera as VisionCamera } from 'react-native-vision-camera'; import { useCameraDevice, useCameraFormat, useCameraPermission } from 'react-native-vision-camera'; import { useCropGestures } from './useCropGestures'; type UseCameraParams = { insetsTop: number; isScreenFocused: boolean; addLog: (foodName: string, photoUri?: string, logId?: string) => void; attachPhoto: (logId: string, photoUri: string) => void; mountedRef: MutableRefObject; showAlert: (title: string, message?: string) => void; }; const { width: SCREEN_WIDTH } = Dimensions.get('window'); export function useCamera({ insetsTop, isScreenFocused, addLog, attachPhoto, mountedRef, showAlert, }: UseCameraParams) { const strings = useStrings(); const { impact, ImpactStyle } = useHaptics(); const device = useCameraDevice('back'); const format = useCameraFormat(device, [ { fps: 60 }, { photoResolution: { width: 3840, height: 2160 } }, ]); const { hasPermission, requestPermission: requestCameraPermission } = useCameraPermission(); const cameraRef = useRef(null); const [cameraMode, setCameraMode] = useState('fullscreen'); const [cameraReady, setCameraReady] = useState(false); const [zoom, setZoom] = useState(1); const [focusPoint, setFocusPoint] = useState<{ x: number; y: number } | null>(null); const cameraOn = cameraMode !== 'off'; const isCropMode = cameraMode === 'cropped'; const showCropOverlay = isCropMode; const zoomShared = useSharedValue(1); const zoomOffset = useSharedValue(1); const { minZoom, maxZoom } = useMemo( () => ({ minZoom: device?.minZoom ?? 1, maxZoom: Math.min(device?.maxZoom ?? 5, 5) }), [device?.minZoom, device?.maxZoom] ); const deviceMinZoom = device?.minZoom ?? 1; useEffect(() => { zoomShared.value = deviceMinZoom; setZoom(deviceMinZoom); }, [deviceMinZoom, zoomShared]); const cameraActive = isScreenFocused; useEffect(() => { if (!cameraActive) setCameraReady(false); }, [cameraActive]); const initialLoadDoneRef = useRef(false); useEffect(() => { let active = true; AsyncStorage.getItem(STORAGE_KEYS.CAMERA_MODE) .then(cData => { if (!active) return; if (cData && ['off', 'fullscreen', 'cropped'].includes(cData)) { setCameraMode(cData as CameraMode); } }) .finally(() => { if (active) initialLoadDoneRef.current = true; }); return () => { active = false; }; }, []); useEffect(() => { if (!initialLoadDoneRef.current) return; AsyncStorage.setItem(STORAGE_KEYS.CAMERA_MODE, cameraMode); }, [cameraMode]); const { cropBoxWidth, cropBoxHeight, cropGestures, cropAreaCenterY } = useCropGestures({ insetsTop, }); const focusAt = useCallback( (x: number, y: number) => { if (!cameraOn) return; Keyboard.dismiss(); impact(); setFocusPoint({ x, y }); }, [cameraOn, impact] ); useEffect(() => { if (focusPoint) { cameraRef.current?.focus(focusPoint).catch(() => {}); } }, [focusPoint]); const cameraGesture = useMemo(() => { const tap = Gesture.Tap().onEnd(e => { 'worklet'; runOnJS(focusAt)(e.x, e.y); }); const pinch = Gesture.Pinch() .onStart(() => { 'worklet'; zoomOffset.value = zoomShared.value; }) .onUpdate(e => { 'worklet'; const newZoom = Math.max(minZoom, Math.min(zoomOffset.value * e.scale, maxZoom)); zoomShared.value = newZoom; runOnJS(setZoom)(newZoom); }); return Gesture.Simultaneous(tap, pinch); }, [focusAt, minZoom, maxZoom, zoomOffset, zoomShared]); const takePhoto = useCallback(async () => { if ( !cameraRef.current || !cameraReady || !cameraOn || !cameraActive || !device || !hasPermission ) return; impact(ImpactStyle.Medium); const logId = Crypto.randomUUID(); const entryRoute = showCropOverlay ? 'camera-cropped' : 'camera-full'; startTrace(logId, entryRoute); startSpan(logId, 'photo-capture'); addLog('', undefined, logId); try { const photo = await cameraRef.current.takePhoto(); endSpan(logId, 'photo-capture'); if (!mountedRef.current) return; let photoUri = `file://${photo.path}`; if (showCropOverlay) { startSpan(logId, 'image-crop'); const screenH = Dimensions.get('window').height; const boxW = cropBoxWidth.value; const boxH = cropBoxHeight.value; const boxLeft = (SCREEN_WIDTH - boxW) / 2; const boxTop = cropAreaCenterY - boxH / 2; const rotated = photo.width > photo.height; const dw = rotated ? photo.height : photo.width; const dh = rotated ? photo.width : photo.height; const f = Math.max(SCREEN_WIDTH / dw, screenH / dh); const ox = (SCREEN_WIDTH - dw * f) / 2; const oy = (screenH - dh * f) / 2; const s = 1 / f; const x = Math.max(0, Math.round((boxLeft - ox) * s)); const y = Math.max(0, Math.round((boxTop - oy) * s)); const width = Math.max(1, Math.min(Math.round(boxW * s), dw - x)); const height = Math.max(1, Math.min(Math.round(boxH * s), dh - y)); photoUri = await cropImage(photoUri, { x, y, width, height }); endSpan(logId, 'image-crop'); if (!mountedRef.current) return; } startSpan(logId, 'image-persist'); const permanentUri = await persistImage(photoUri, logId); endSpan(logId, 'image-persist'); if (!mountedRef.current) return; attachPhoto(logId, permanentUri); } catch (e) { endSpan(logId, 'photo-capture', e); if (!mountedRef.current) return; showAlert(strings.common.error, strings.addLog.camera.failedToTakePhoto(String(e))); } }, [ addLog, attachPhoto, cameraActive, cameraOn, cameraReady, cropAreaCenterY, cropBoxHeight, cropBoxWidth, device, hasPermission, mountedRef, showAlert, showCropOverlay, impact, ImpactStyle, strings, ]); return { device, format, cameraRef, hasPermission, requestCameraPermission, cameraMode, setCameraMode, cameraOn, cameraActive, cameraReady, isCropMode, showCropOverlay, setCameraReady, zoom, cameraGesture, cropGestures, cropBoxWidth, cropBoxHeight, cropAreaCenterY, takePhoto, focusPoint, }; }