diff --git a/__mocks__/react-native-vision-camera.ts b/__mocks__/react-native-vision-camera.ts new file mode 100644 index 000000000000..632a902e6bbd --- /dev/null +++ b/__mocks__/react-native-vision-camera.ts @@ -0,0 +1,13 @@ +const useCameraDevice = jest.fn(() => null); +const useCameraFormat = jest.fn(() => null); +const useCameraPermission = jest.fn(() => ({hasPermission: false, requestPermission: jest.fn(() => Promise.resolve(false))})); + +const Camera = Object.assign( + jest.fn(() => null), + { + getCameraPermissionStatus: jest.fn(() => 'not-determined'), + requestCameraPermission: jest.fn(() => Promise.resolve('granted')), + }, +); + +export {Camera, useCameraDevice, useCameraFormat, useCameraPermission}; diff --git a/assets/images/camera-flip.svg b/assets/images/camera-flip.svg new file mode 100644 index 000000000000..2c3baf717701 --- /dev/null +++ b/assets/images/camera-flip.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index fdd73f8f0c9b..50d4453f6ae4 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -590,7 +590,6 @@ "../../src/hooks/useLazyAsset.ts" "react-hooks/set-state-in-effect" 1 "../../src/hooks/useLifecycleActions.tsx" "rulesdir/no-useOnyx-dependencies-arg" 1 "../../src/hooks/useListKeyboardNav.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/hooks/useNativeCamera.ts" "react-hooks/refs" 1 "../../src/hooks/useNewTransactions.ts" "react-hooks/refs" 2 "../../src/hooks/useOnboardingTaskInformation.ts" "rulesdir/no-useOnyx-dependencies-arg" 1 "../../src/hooks/useOnyx.ts" "@typescript-eslint/no-unsafe-type-assertion" 10 diff --git a/src/components/AttachmentPicker/AttachmentCamera.tsx b/src/components/AttachmentPicker/AttachmentCamera.tsx new file mode 100644 index 000000000000..76171e260a10 --- /dev/null +++ b/src/components/AttachmentPicker/AttachmentCamera.tsx @@ -0,0 +1,323 @@ +import ActivityIndicator from '@components/ActivityIndicator'; +import Button from '@components/ButtonComposed'; +import Icon from '@components/Icon'; +import ImageSVG from '@components/ImageSVG'; +import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; +import Text from '@components/Text'; + +import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import {useTapToFocusGesture} from '@hooks/useNativeCamera'; +import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {showCameraPermissionsAlert} from '@libs/fileDownload/FileUtils'; +import getPhotoSource from '@libs/fileDownload/getPhotoSource'; +import getReceiptsUploadFolderPath from '@libs/getReceiptsUploadFolderPath'; +import Log from '@libs/Log'; + +import CameraPermission from '@pages/iou/request/step/IOURequestStepScan/CameraPermission'; + +import variables from '@styles/variables'; + +import CONST from '@src/CONST'; + +import type {Camera, PhotoFile} from 'react-native-vision-camera'; + +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {Modal, View} from 'react-native'; +import {GestureDetector} from 'react-native-gesture-handler'; +import {RESULTS} from 'react-native-permissions'; +import Animated from 'react-native-reanimated'; +import {useCameraDevice, useCameraFormat, Camera as VisionCamera} from 'react-native-vision-camera'; + +type CapturedPhoto = { + uri: string; + fileName: string; + type: string; + width: number; + height: number; +}; + +type AttachmentCameraProps = { + /** Whether the camera modal is visible */ + isVisible: boolean; + + /** Callback when a photo is captured */ + onCapture: (photos: CapturedPhoto[]) => void; + + /** Callback when the camera is closed */ + onClose: () => void; +}; + +function AttachmentCamera({isVisible, onCapture, onClose}: AttachmentCameraProps) { + const theme = useTheme(); + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const insets = useSafeAreaInsets(); + const StyleUtils = useStyleUtils(); + const lazyIcons = useMemoizedLazyExpensifyIcons(['Bolt', 'boltSlash', 'CameraFlip', 'Close']); + const lazyIllustrations = useMemoizedLazyIllustrations(['Shutter', 'Hand']); + + const [cameraPosition, setCameraPosition] = useState<'back' | 'front'>('back'); + const [flash, setFlash] = useState(false); + const [cameraPermissionStatus, setCameraPermissionStatus] = useState(null); + const isCapturing = useRef(false); + const isActiveRef = useRef(false); + const cameraRef = useRef(null); + + const device = useCameraDevice(cameraPosition, { + physicalDevices: ['wide-angle-camera', 'ultra-wide-angle-camera'], + }); + + const format = useCameraFormat(device, [ + {photoAspectRatio: CONST.RECEIPT_CAMERA.PHOTO_ASPECT_RATIO}, + {photoResolution: {width: CONST.RECEIPT_CAMERA.PHOTO_WIDTH, height: CONST.RECEIPT_CAMERA.PHOTO_HEIGHT}}, + ]); + const hasFlash = !!device?.hasFlash; + // Format dimensions are in landscape orientation, so height/width gives portrait aspect ratio + const cameraAspectRatio = useMemo(() => (format ? format.photoHeight / format.photoWidth : undefined), [format]); + + const {tapGesture, cameraFocusIndicatorAnimatedStyle} = useTapToFocusGesture(cameraRef, device?.supportsFocus ?? false); + + // Permission management + const askForPermissions = useCallback(() => { + CameraPermission.requestCameraPermission?.() + .then((status: string) => { + setCameraPermissionStatus(status); + if (status === RESULTS.BLOCKED) { + showCameraPermissionsAlert(translate); + } + }) + .catch(() => { + setCameraPermissionStatus(RESULTS.UNAVAILABLE); + }); + }, [translate]); + + // Track visibility in a ref so async takePhoto callbacks can detect stale sessions + useEffect(() => { + isActiveRef.current = isVisible; + }, [isVisible]); + + // Refresh permissions when modal becomes visible and auto-request if denied + useEffect(() => { + if (!isVisible) { + return; + } + + let ignore = false; + CameraPermission?.getCameraPermissionStatus?.() + .then((status: string) => { + if (ignore) { + return; + } + setCameraPermissionStatus(status); + if (status === RESULTS.DENIED) { + askForPermissions(); + } + }) + .catch(() => { + if (ignore) { + return; + } + setCameraPermissionStatus(RESULTS.UNAVAILABLE); + }); + return () => { + ignore = true; + }; + }, [isVisible, askForPermissions]); + + const capturePhoto = useCallback(() => { + if (cameraPermissionStatus !== RESULTS.GRANTED) { + askForPermissions(); + return; + } + + if (!cameraRef.current || isCapturing.current) { + return; + } + + isCapturing.current = true; + + const path = getReceiptsUploadFolderPath(); + + cameraRef.current + .takePhoto({ + flash: flash && hasFlash ? 'on' : 'off', + path, + }) + .then((photo: PhotoFile) => { + // Discard capture if the camera was closed while takePhoto was in-flight + if (!isActiveRef.current) { + return; + } + const uri = getPhotoSource(photo.path); + const fileName = photo.path.substring(photo.path.lastIndexOf('/') + 1) || `photo_${Date.now()}.jpg`; + + onCapture([ + { + uri, + fileName, + type: 'image/jpeg', + width: photo.width, + height: photo.height, + }, + ]); + }) + .catch((error: Error) => { + Log.warn('Error capturing photo', {error: error.message}); + }) + .finally(() => { + isCapturing.current = false; + }); + }, [askForPermissions, cameraPermissionStatus, flash, hasFlash, onCapture]); + + const handleClose = useCallback(() => { + isCapturing.current = false; + setFlash(false); + setCameraPosition('back'); + onClose(); + }, [onClose]); + + return ( + + + {/* Close button */} + + + + + + + {/* Camera area */} + + {cameraPermissionStatus !== RESULTS.GRANTED && ( + + + {translate('receipt.takePhoto')} + {translate('receipt.cameraAccess')} + + + )} + {cameraPermissionStatus === RESULTS.GRANTED && device == null && ( + + + + )} + {cameraPermissionStatus === RESULTS.GRANTED && device != null && ( + + + + + + + + + )} + + + {/* Bottom controls */} + + {/* Flash toggle */} + setFlash((prevFlash) => !prevFlash)} + sentryLabel="AttachmentCamera-Flash" + > + + + + {/* Shutter button */} + + + + + {/* Camera flip */} + setCameraPosition((prev) => (prev === 'back' ? 'front' : 'back'))} + sentryLabel="AttachmentCamera-FlipCamera" + > + + + + + + ); +} + +export default AttachmentCamera; +export type {CapturedPhoto}; diff --git a/src/components/AttachmentPicker/index.native.tsx b/src/components/AttachmentPicker/index.native.tsx index 6b8cde6c3ce9..83fa8f9f9591 100644 --- a/src/components/AttachmentPicker/index.native.tsx +++ b/src/components/AttachmentPicker/index.native.tsx @@ -32,9 +32,10 @@ import RNFetchBlob from 'react-native-blob-util'; import {launchImageLibrary} from 'react-native-image-picker'; import ImageSize from 'react-native-image-size'; +import type {CapturedPhoto} from './AttachmentCamera'; import type AttachmentPickerProps from './types'; -import launchCamera from './launchCamera/launchCamera'; +import AttachmentCamera from './AttachmentCamera'; const EXTENSION_TO_NATIVE_TYPE: Record = { pdf: String(types.pdf), @@ -62,14 +63,23 @@ type LocalCopy = { type: string | null; }; -type Item = { - /** The icon associated with the item. */ - icon: IconAsset; - /** The key in the translations file to use for the title */ - textTranslationKey: TranslationPaths; - /** Function to call when the user clicks the item */ - pickAttachment: () => Promise; -}; +type Item = + | { + /** The icon associated with the item. */ + icon: IconAsset; + /** The key in the translations file to use for the title */ + textTranslationKey: TranslationPaths; + /** Function to call when the user clicks the item */ + pickAttachment: () => Promise; + } + | { + /** The icon associated with the item. */ + icon: IconAsset; + /** The key in the translations file to use for the title */ + textTranslationKey: TranslationPaths; + /** Direct action that doesn't go through the promise-based selectItem flow */ + onPress: () => void; + }; /** * Ensures asset has proper fileName and type properties @@ -168,6 +178,7 @@ function AttachmentPicker({ const icons = useMemoizedLazyExpensifyIcons(['Camera', 'Gallery', 'Paperclip']); const styles = useThemeStyles(); const [isVisible, setIsVisible] = useState(false); + const [showAttachmentCamera, setShowAttachmentCamera] = useState(false); const StyleUtils = useStyleUtils(); const theme = useTheme(); @@ -190,10 +201,20 @@ function AttachmentPicker({ [translate], ); + /** + * Launch the in-app VisionCamera instead of the external system camera. + * Opens the camera modal directly — bypasses the promise-based selectItem flow. + * handleCameraCapture / handleCameraClose handle completion. + */ + const launchInAppCamera = useCallback(() => { + onOpenPicker?.(); + setShowAttachmentCamera(true); + }, [onOpenPicker]); + /** * Common image picker handling * - * @param {function} imagePickerFunc - RNImagePicker.launchCamera or RNImagePicker.launchImageLibrary + * @param {function} imagePickerFunc - RNImagePicker.launchImageLibrary */ const showImagePicker = useCallback( (imagePickerFunc: (options: CameraOptions, callback: Callback) => Promise): Promise => @@ -359,12 +380,12 @@ function AttachmentPicker({ data.unshift({ icon: icons.Camera, textTranslationKey: 'attachmentPicker.takePhoto', - pickAttachment: () => showImagePicker(launchCamera), + onPress: launchInAppCamera, }); } return data; - }, [icons.Camera, icons.Paperclip, icons.Gallery, showDocumentPicker, shouldHideGalleryOption, shouldHideCameraOption, showImagePicker]); + }, [icons.Camera, icons.Paperclip, icons.Gallery, showDocumentPicker, shouldHideGalleryOption, shouldHideCameraOption, launchInAppCamera, showImagePicker]); const [focusedIndex, setFocusedIndex] = useArrowKeyFocusManager({initialFocusedIndex: -1, maxIndex: menuItemData.length - 1, isActive: isVisible}); @@ -487,6 +508,31 @@ function AttachmentPicker({ [handleImageProcessingError, shouldValidateImage, showGeneralAlert, showImageCorruptionAlert], ); + const handleCameraCapture = useCallback( + (photos: CapturedPhoto[]) => { + setShowAttachmentCamera(false); + const assets: Asset[] = photos.map((photo) => ({ + uri: photo.uri, + fileName: photo.fileName, + type: photo.type, + width: photo.width, + height: photo.height, + })); + Promise.resolve(pickAttachment(assets)).finally(() => { + onClosed.current(); + delete onModalHide.current; + }); + }, + [pickAttachment], + ); + + const handleCameraClose = useCallback(() => { + setShowAttachmentCamera(false); + onCanceled.current(); + onClosed.current(); + delete onModalHide.current; + }, []); + /** * Opens the attachment modal, or directly launches the document picker when shouldSkipAttachmentTypeModal is true. */ @@ -522,6 +568,22 @@ function AttachmentPicker({ */ const selectItem = useCallback( (item: Item) => { + /* Items with onPress (e.g. the in-app camera) handle their own flow and don't go + * through the promise-based pickAttachment chain. Defer the launch to onModalHide so + * the camera modal only presents after the popover has fully dismissed — presenting a + * second modal while the first is still dismissing fails silently on iOS, which is what + * caused the "camera doesn't open"/"app loads infinitely" regressions. */ + if ('onPress' in item) { + onModalHide.current = () => { + setTimeout(() => { + item.onPress(); + delete onModalHide.current; + }, 200); + }; + close(); + return; + } + onOpenPicker?.(); /* setTimeout delays execution to the frame after the modal closes * without this on iOS closing the modal closes the gallery/camera as well */ @@ -598,6 +660,11 @@ function AttachmentPicker({ ))} + {renderChildren()} ); diff --git a/src/components/AttachmentPicker/launchCamera/launchCamera.android.ts b/src/components/AttachmentPicker/launchCamera/launchCamera.android.ts deleted file mode 100644 index 9a20f6918208..000000000000 --- a/src/components/AttachmentPicker/launchCamera/launchCamera.android.ts +++ /dev/null @@ -1,34 +0,0 @@ -import {PermissionsAndroid} from 'react-native'; -import {launchCamera as launchCameraImagePicker} from 'react-native-image-picker'; - -import type {LaunchCamera} from './types'; - -import {ErrorLaunchCamera} from './types'; - -/** - * Launching the camera for Android involves checking for permissions - * And only then starting the camera - * If the user deny permission the callback will be called with an error response - * in the same format as the error returned by react-native-image-picker - */ -const launchCamera: LaunchCamera = (options, callback) => { - // Checks current camera permissions and prompts the user in case they aren't granted - PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.CAMERA) - .then((permission) => { - if (permission !== PermissionsAndroid.RESULTS.GRANTED) { - throw new ErrorLaunchCamera('User did not grant permissions', 'permission'); - } - - launchCameraImagePicker(options, callback); - }) - .catch((error: ErrorLaunchCamera) => { - /* Intercept the permission error as well as any other errors and call the callback - * follow the same pattern expected for image picker results */ - callback({ - errorMessage: error.message, - errorCode: error.errorCode || 'others', - }); - }); -}; - -export default launchCamera; diff --git a/src/components/AttachmentPicker/launchCamera/launchCamera.ios.ts b/src/components/AttachmentPicker/launchCamera/launchCamera.ios.ts deleted file mode 100644 index b56d77ca61c7..000000000000 --- a/src/components/AttachmentPicker/launchCamera/launchCamera.ios.ts +++ /dev/null @@ -1,34 +0,0 @@ -import {launchCamera as launchCameraImagePicker} from 'react-native-image-picker'; -import {PERMISSIONS, request, RESULTS} from 'react-native-permissions'; - -import type {LaunchCamera} from './types'; - -import {ErrorLaunchCamera} from './types'; - -/** - * Launching the camera for iOS involves checking for permissions - * And only then starting the camera - * If the user deny permission the callback will be called with an error response - * in the same format as the error returned by react-native-image-picker - */ -const launchCamera: LaunchCamera = (options, callback) => { - // Checks current camera permissions and prompts the user in case they aren't granted - request(PERMISSIONS.IOS.CAMERA) - .then((permission) => { - if (permission !== RESULTS.GRANTED) { - throw new ErrorLaunchCamera('User did not grant permissions', 'permission'); - } - - launchCameraImagePicker(options, callback); - }) - .catch((error: ErrorLaunchCamera) => { - /* Intercept the permission error as well as any other errors and call the callback - * follow the same pattern expected for image picker results */ - callback({ - errorMessage: error.message, - errorCode: error.errorCode || 'others', - }); - }); -}; - -export default launchCamera; diff --git a/src/components/AttachmentPicker/launchCamera/launchCamera.ts b/src/components/AttachmentPicker/launchCamera/launchCamera.ts deleted file mode 100644 index dc1f921086de..000000000000 --- a/src/components/AttachmentPicker/launchCamera/launchCamera.ts +++ /dev/null @@ -1,3 +0,0 @@ -import {launchCamera} from 'react-native-image-picker'; - -export default launchCamera; diff --git a/src/components/AttachmentPicker/launchCamera/types.ts b/src/components/AttachmentPicker/launchCamera/types.ts deleted file mode 100644 index fee9268c2f98..000000000000 --- a/src/components/AttachmentPicker/launchCamera/types.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * A callback function used to handle the response from the image picker. - * - * @param response - The response object containing information about the picked images or any errors encountered. - */ -type Callback = (response: ImagePickerResponse) => void; - -type OptionsCommon = { - /** Specifies the type of media to be captured. */ - mediaType: MediaType; - /** Specifies the maximum width of the media to be captured. */ - maxWidth?: number; - /** Specifies the maximum height of the media to be captured. */ - maxHeight?: number; - /** Specifies the quality of the photo to be captured. */ - quality?: PhotoQuality; - /** Specifies the video quality for video capture. */ - videoQuality?: AndroidVideoOptions | IOSVideoOptions; - /** Specifies whether to include the media in base64 format. */ - includeBase64?: boolean; - /** Specifies whether to include extra information about the captured media. */ - includeExtra?: boolean; - /** Specifies the presentation style for the media picker. */ - presentationStyle?: 'currentContext' | 'fullScreen' | 'pageSheet' | 'formSheet' | 'popover' | 'overFullScreen' | 'overCurrentContext'; -}; - -type CameraOptions = OptionsCommon & { - /** Specifies the maximum duration limit. */ - durationLimit?: number; - /** Specifies whether to save captured media. */ - saveToPhotos?: boolean; - /** Specifies the type of camera to be used. */ - cameraType?: CameraType; -}; - -type Asset = { - /** Base64 representation of the asset. */ - base64?: string; - /** URI pointing to the asset. */ - uri?: string; - /** Width of the asset. */ - width?: number; - /** Height of the asset. */ - height?: number; - /** Size of the asset file in bytes. */ - fileSize?: number; - /** Type of the asset. */ - type?: string; - /** Name of the asset file. */ - fileName?: string; - /** Duration of the asset. */ - duration?: number; - /** Bitrate of the asset. */ - bitrate?: number; - /** Timestamp of when the asset was created or modified. */ - timestamp?: string; - /** ID of the asset. */ - id?: string; -}; - -type ImagePickerResponse = { - /** Indicates whether the image picker operation was canceled. */ - didCancel?: boolean; - /** The error code, if an error occurred during the image picking process. */ - errorCode?: ErrorCode; - /** A descriptive error message, if an error occurred during the image picking process. */ - errorMessage?: string; - /** An array of assets representing the picked images. */ - assets?: Asset[]; -}; - -/** Represents the quality options. */ -type PhotoQuality = 0 | 0.1 | 0.2 | 0.3 | 0.4 | 0.5 | 0.6 | 0.7 | 0.8 | 0.9 | 1; - -/** Represents the type of camera to be used. */ -type CameraType = 'back' | 'front'; - -/** Represents the type of media to be captured. */ -type MediaType = 'photo' | 'video' | 'mixed'; - -/** Represents the quality options for video capture on Android devices. */ -type AndroidVideoOptions = 'low' | 'high'; - -/** Represents the quality options for video capture on iOS devices. */ -type IOSVideoOptions = 'low' | 'medium' | 'high'; - -/** Represents various error codes that may occur during camera operations. */ -type ErrorCode = 'camera_unavailable' | 'permission' | 'others'; - -class ErrorLaunchCamera extends Error { - /** The error code associated with the error. */ - errorCode: ErrorCode; - - constructor(message: string, errorCode: ErrorCode) { - super(message); - this.errorCode = errorCode; - } -} - -/** - * A function used to launch the camera with specified options and handle the callback. - * - * @param options - The options for the camera, specifying various settings. - * @param callback - The callback function to handle the response from the camera operation. - */ -type LaunchCamera = (options: CameraOptions, callback: Callback) => void; - -export {ErrorLaunchCamera}; -export type {LaunchCamera}; diff --git a/src/components/Icon/chunks/expensify-icons.chunk.ts b/src/components/Icon/chunks/expensify-icons.chunk.ts index 6cb65414850e..c8cf3d1d6ac2 100644 --- a/src/components/Icon/chunks/expensify-icons.chunk.ts +++ b/src/components/Icon/chunks/expensify-icons.chunk.ts @@ -36,6 +36,7 @@ import Building from '@assets/images/building.svg'; import Buildings from '@assets/images/buildings.svg'; import CalendarSolid from '@assets/images/calendar-solid.svg'; import Calendar from '@assets/images/calendar.svg'; +import CameraFlip from '@assets/images/camera-flip.svg'; import Camera from '@assets/images/camera.svg'; import CarCircleSlash from '@assets/images/car-circle-slash.svg'; import CarPlus from '@assets/images/car-plus.svg'; @@ -317,6 +318,7 @@ const Expensicons = { Buildings, Calendar, Camera, + CameraFlip, Car, CarPlus, Cash, diff --git a/src/hooks/useNativeCamera.ts b/src/hooks/useNativeCamera.ts index c3170dfe1487..12e30f9be32b 100644 --- a/src/hooks/useNativeCamera.ts +++ b/src/hooks/useNativeCamera.ts @@ -11,6 +11,7 @@ import CameraPermission from '@pages/iou/request/step/IOURequestStepScan/CameraP import ONYXKEYS from '@src/ONYXKEYS'; import {getEmptyObject} from '@src/types/utils/EmptyObject'; +import type React from 'react'; import type {Camera, Point} from 'react-native-vision-camera'; import {useFocusEffect} from '@react-navigation/core'; @@ -72,41 +73,7 @@ function useNativeCamera({context, onFocusStart, onFocusCleanup}: UseNativeCamer }); }, [translate]); - // Focus indicator animations - const focusIndicatorOpacity = useSharedValue(0); - const focusIndicatorScale = useSharedValue(2); - const focusIndicatorPosition = useSharedValue({x: 0, y: 0}); - - const cameraFocusIndicatorAnimatedStyle = useAnimatedStyle(() => ({ - opacity: focusIndicatorOpacity.get(), - transform: [{translateX: focusIndicatorPosition.get().x}, {translateY: focusIndicatorPosition.get().y}, {scale: focusIndicatorScale.get()}], - })); - - const focusCamera = useCallback((point: Point) => { - if (!camera.current) { - return; - } - - camera.current.focus(point).catch((error: Record) => { - if (error.message === '[unknown/unknown] Cancelled by another startFocusAndMetering()') { - return; - } - Log.warn('Error focusing camera', error); - }); - }, []); - - const tapGesture = Gesture.Tap() - .enabled(device?.supportsFocus ?? false) - .onStart((ev: {x: number; y: number}) => { - const point = {x: ev.x, y: ev.y}; - - focusIndicatorOpacity.set(withSequence(withTiming(0.8, {duration: 250}), withDelay(1000, withTiming(0, {duration: 250})))); - focusIndicatorScale.set(2); - focusIndicatorScale.set(withSpring(1, {damping: 10, stiffness: 200})); - focusIndicatorPosition.set(point); - - scheduleOnRN(focusCamera, point); - }); + const {tapGesture, cameraFocusIndicatorAnimatedStyle} = useTapToFocusGesture(camera, device?.supportsFocus ?? false); // Refresh camera permission on screen focus and app state changes useFocusEffect( @@ -166,4 +133,55 @@ function useNativeCamera({context, onFocusStart, onFocusCleanup}: UseNativeCamer }; } +/** + * Focuses the camera at the given point. Kept as a module-level helper (outside any hook/component) + * so React Compiler does not analyze the `cameraRef.current` access -- reading a ref's `.current` + * inside a hook body trips the "no ref access during render" rule, which makes OXC bail on the whole + * file and diverge from Babel. The hook only ever passes the ref object through, never dereferences it. + */ +function focusCameraAtPoint(cameraRef: React.RefObject, point: Point) { + if (!cameraRef.current) { + return; + } + + cameraRef.current.focus(point).catch((error: Record) => { + if (error.message === '[unknown/unknown] Cancelled by another startFocusAndMetering()') { + return; + } + Log.warn('Error focusing camera', error); + }); +} + +/** + * Encapsulates tap-to-focus gesture handling for VisionCamera. + */ +function useTapToFocusGesture(cameraRef: React.RefObject, supportsFocus: boolean) { + const focusIndicatorOpacity = useSharedValue(0); + const focusIndicatorScale = useSharedValue(2); + const focusIndicatorPosition = useSharedValue({x: 0, y: 0}); + + const cameraFocusIndicatorAnimatedStyle = useAnimatedStyle(() => ({ + opacity: focusIndicatorOpacity.get(), + transform: [{translateX: focusIndicatorPosition.get().x}, {translateY: focusIndicatorPosition.get().y}, {scale: focusIndicatorScale.get()}], + })); + + const focusCamera = useCallback((point: Point) => focusCameraAtPoint(cameraRef, point), [cameraRef]); + + const tapGesture = Gesture.Tap() + .enabled(supportsFocus) + .onStart((ev: {x: number; y: number}) => { + const point = {x: ev.x, y: ev.y}; + + focusIndicatorOpacity.set(withSequence(withTiming(0.8, {duration: 250}), withDelay(1000, withTiming(0, {duration: 250})))); + focusIndicatorScale.set(2); + focusIndicatorScale.set(withSpring(1, {damping: 10, stiffness: 200})); + focusIndicatorPosition.set(point); + + scheduleOnRN(focusCamera, point); + }); + + return {tapGesture, cameraFocusIndicatorAnimatedStyle}; +} + export default useNativeCamera; +export {useTapToFocusGesture}; diff --git a/src/languages/de.ts b/src/languages/de.ts index 95a23c719dfd..4f995d3b98ba 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1216,6 +1216,7 @@ const translations: TranslationDeepObject = { dropTitle: 'Lass es los', dropMessage: 'Datei hierher ziehen', flash: 'Blitz', + flipCamera: 'Kamera wechseln', multiScan: 'Mehrfachscan', shutter: 'Verschluss', gallery: 'Galerie', diff --git a/src/languages/en.ts b/src/languages/en.ts index 057d87f4e356..4566bf1f3c82 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1284,6 +1284,7 @@ const translations = { dropTitle: 'Let it go', dropMessage: 'Drop your file here', flash: 'flash', + flipCamera: 'Flip camera', multiScan: 'multi-scan', shutter: 'shutter', gallery: 'gallery', diff --git a/src/languages/es.ts b/src/languages/es.ts index 9750a85de185..b5a667769042 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1175,6 +1175,7 @@ const translations: TranslationDeepObject = { dropTitle: 'Suéltalo', dropMessage: 'Suelta tu archivo aquí', flash: 'flash', + flipCamera: 'Cambiar cámara', multiScan: 'escaneo múltiple', shutter: 'obturador', gallery: 'galería', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 1b75c445f283..b198f30d9c45 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1220,6 +1220,7 @@ const translations: TranslationDeepObject = { dropTitle: 'Laisse tomber', dropMessage: 'Déposez votre fichier ici', flash: 'flash', + flipCamera: 'Retourner la caméra', multiScan: 'numérisation multiple', shutter: 'obturateur', gallery: 'galerie', diff --git a/src/languages/it.ts b/src/languages/it.ts index dfcdf2ee5181..1705d4287258 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1215,6 +1215,7 @@ const translations: TranslationDeepObject = { dropTitle: 'Lascia perdere', dropMessage: 'Rilascia qui il tuo file', flash: 'flash', + flipCamera: 'Cambia fotocamera', multiScan: 'scansione multipla', shutter: 'otturatore', gallery: 'galleria', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 0ffc19ec41b5..209479d4d6d7 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1198,6 +1198,7 @@ const translations: TranslationDeepObject = { dropTitle: '手放して', dropMessage: 'ここにファイルをドロップしてください', flash: 'フラッシュ', + flipCamera: 'カメラ切替', multiScan: 'マルチスキャン', shutter: 'シャッター', gallery: 'ギャラリー', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index d34f8495295a..2bf3f4153090 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1214,6 +1214,7 @@ const translations: TranslationDeepObject = { dropTitle: 'Laat het los', dropMessage: 'Zet je bestand hier neer', flash: 'flits', + flipCamera: 'Camera wisselen', multiScan: 'meerscannen', shutter: 'sluiter', gallery: 'galerij', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 5171d9d5e5d5..c6429c56266e 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1210,6 +1210,7 @@ const translations: TranslationDeepObject = { dropTitle: 'Odpuść to', dropMessage: 'Upuść tutaj plik', flash: 'błysk', + flipCamera: 'Przełącz kamerę', multiScan: 'wielokrotne skanowanie', shutter: 'migawka', gallery: 'galeria', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index d4c1672a2cb5..8aca09077875 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1214,6 +1214,7 @@ const translations: TranslationDeepObject = { dropTitle: 'Deixe pra lá', dropMessage: 'Solte seu arquivo aqui', flash: 'flash', + flipCamera: 'Alternar câmera', multiScan: 'escaneamento múltiplo', shutter: 'obturador', gallery: 'galeria', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 5547a6a118a9..d6332d8b1196 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1166,6 +1166,7 @@ const translations: TranslationDeepObject = { dropTitle: '随它去', dropMessage: '将文件拖放到此处', flash: '闪光', + flipCamera: '切换相机', multiScan: '多重扫描', shutter: '快门', gallery: '图库',