Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9070,6 +9070,13 @@ const CONST = {
OPTION_CARD_PICKER: {
OPTION_ITEM: 'OptionCardPicker-OptionItem',
},
ATTACHMENT_CAMERA: {
CLOSE: 'AttachmentCamera-Close',
FLASH: 'AttachmentCamera-Flash',
SHUTTER: 'AttachmentCamera-Shutter',
FLIP_CAMERA: 'AttachmentCamera-FlipCamera',
PERMISSION_PROMPT_BUTTON: 'AttachmentCamera-PermissionPromptButton',
},
ATTACHMENT_CAROUSEL: {
PREVIOUS_BUTTON: 'AttachmentCarousel-PreviousButton',
NEXT_BUTTON: 'AttachmentCarousel-NextButton',
Expand Down
214 changes: 116 additions & 98 deletions src/components/AttachmentPicker/AttachmentCamera.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import Icon from '@components/Icon';
import ImageSVG from '@components/ImageSVG';
import Modal from '@components/Modal';
import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
import ScrollView from '@components/ScrollView';
import Text from '@components/Text';

import useIsPlatformMuted from '@hooks/useIsPlatformMuted';
Expand Down Expand Up @@ -91,7 +92,12 @@ function AttachmentCamera({isVisible, onCapture, onClose, onModalHide}: Attachme

const format = useCameraFormat(device, [
{photoAspectRatio: CONST.RECEIPT_CAMERA.PHOTO_ASPECT_RATIO},
{photoResolution: {width: CONST.RECEIPT_CAMERA.PHOTO_WIDTH, height: CONST.RECEIPT_CAMERA.PHOTO_HEIGHT}},
{
photoResolution: {
width: CONST.RECEIPT_CAMERA.PHOTO_WIDTH,
height: CONST.RECEIPT_CAMERA.PHOTO_HEIGHT,
},
},
getVideoResolutionFormatFilter(windowWidth, windowHeight),
]);
const hasFlash = !!device?.hasFlash;
Expand Down Expand Up @@ -182,6 +188,9 @@ function AttachmentCamera({isVisible, onCapture, onClose, onModalHide}: Attachme
]);
})
.catch((error: Error) => {
if (!isActiveRef.current) {
return;
}
Alert.alert(translate('receipt.cameraErrorTitle'), translate('receipt.cameraErrorMessage'));
logCameraCaptureFailed(error);
})
Expand All @@ -191,11 +200,15 @@ function AttachmentCamera({isVisible, onCapture, onClose, onModalHide}: Attachme
};

const handleCameraError = (error: CameraRuntimeError) => {
if (!isActiveRef.current) {
return;
}
Alert.alert(translate('receipt.cameraErrorTitle'), translate('receipt.cameraErrorMessage'));
logCameraRuntimeError({code: error.code, message: error.message});
};

const handleClose = () => {
isActiveRef.current = false;
isCapturing.current = false;
setFlash(false);
setCameraPosition('back');
Expand All @@ -217,7 +230,7 @@ function AttachmentCamera({isVisible, onCapture, onClose, onModalHide}: Attachme
role={CONST.ROLE.BUTTON}
accessibilityLabel={translate('common.close')}
onPress={handleClose}
sentryLabel="AttachmentCamera-Close"
sentryLabel={CONST.SENTRY_LABEL.ATTACHMENT_CAMERA.CLOSE}
>
<Icon
height={variables.iconSizeNormal}
Expand All @@ -228,106 +241,111 @@ function AttachmentCamera({isVisible, onCapture, onClose, onModalHide}: Attachme
</PressableWithFeedback>
</View>

<View style={[styles.flex1]}>
{cameraPermissionStatus !== RESULTS.GRANTED && (
<View style={[styles.cameraView, styles.permissionView, styles.userSelectNone]}>
<View style={[styles.flex1, isLandscape && styles.flexRow]}>
<View style={styles.flex1}>
{cameraPermissionStatus !== RESULTS.GRANTED && (
Comment thread
ShridharGoel marked this conversation as resolved.
<ScrollView contentContainerStyle={styles.flexGrow1}>
<View style={[styles.cameraView, isLandscape ? styles.permissionViewLandscape : styles.permissionView, styles.userSelectNone]}>
<ImageSVG
contentFit="contain"
src={lazyIllustrations.Hand}
width={CONST.RECEIPT.HAND_ICON_WIDTH}
height={CONST.RECEIPT.HAND_ICON_HEIGHT}
style={styles.pb5}
/>
<Text style={[styles.textFileUpload]}>{translate('receipt.takePhoto')}</Text>
<Text style={[styles.subTextFileUpload]}>{translate('receipt.cameraAccess')}</Text>
<Button
variant={CONST.BUTTON_VARIANT.SUCCESS}
accessibilityLabel={translate('common.continue')}
style={[styles.p9, styles.pt5]}
onPress={askForPermissions}
sentryLabel={CONST.SENTRY_LABEL.ATTACHMENT_CAMERA.PERMISSION_PROMPT_BUTTON}
>
<Button.Text>{translate('common.continue')}</Button.Text>
</Button>
</View>
</ScrollView>
)}
{cameraPermissionStatus === RESULTS.GRANTED && device == null && (
<View style={[styles.cameraView, styles.justifyContentCenter, styles.alignItemsCenter]}>
<ActivityIndicator
size={CONST.ACTIVITY_INDICATOR_SIZE.LARGE}
style={[styles.flex1]}
color={theme.textSupporting}
/>
</View>
)}
{cameraPermissionStatus === RESULTS.GRANTED && device != null && (
<View style={[styles.cameraView, styles.alignItemsCenter]}>
<GestureDetector gesture={tapGesture}>
<View style={StyleUtils.getCameraViewfinderStyle(cameraAspectRatio, isLandscape)}>
<VisionCamera
ref={cameraRef}
device={device}
format={format ?? undefined}
style={styles.flex1}
zoom={device.neutralZoom}
photo
isActive={isVisible}
photoQualityBalance="quality"
onError={handleCameraError}
/>
<Animated.View style={[styles.cameraFocusIndicator, cameraFocusIndicatorAnimatedStyle]} />
</View>
</GestureDetector>
</View>
)}
</View>

<View style={[styles.justifyContentAround, styles.alignItemsCenter, styles.p3, !isLandscape && styles.flexRow]}>
<PressableWithFeedback
role={CONST.ROLE.BUTTON}
accessibilityLabel={translate('receipt.flash')}
style={[styles.alignItemsStart, !hasFlash && styles.opacity0]}
disabled={cameraPermissionStatus !== RESULTS.GRANTED || !hasFlash}
onPress={() => setFlash((prevFlash) => !prevFlash)}
sentryLabel={CONST.SENTRY_LABEL.ATTACHMENT_CAMERA.FLASH}
>
<Icon
height={variables.iconSizeMenuItem}
width={variables.iconSizeMenuItem}
src={flash ? lazyIcons.Bolt : lazyIcons.boltSlash}
fill={theme.textSupporting}
/>
</PressableWithFeedback>

<PressableWithFeedback
role={CONST.ROLE.BUTTON}
accessibilityLabel={translate('receipt.shutter')}
style={[styles.alignItemsCenter]}
onPress={capturePhoto}
sentryLabel={CONST.SENTRY_LABEL.ATTACHMENT_CAMERA.SHUTTER}
>
<ImageSVG
contentFit="contain"
src={lazyIllustrations.Hand}
width={CONST.RECEIPT.HAND_ICON_WIDTH}
height={CONST.RECEIPT.HAND_ICON_HEIGHT}
style={styles.pb5}
src={lazyIllustrations.Shutter}
width={CONST.RECEIPT.SHUTTER_SIZE}
height={CONST.RECEIPT.SHUTTER_SIZE}
/>
<Text style={[styles.textFileUpload]}>{translate('receipt.takePhoto')}</Text>
<Text style={[styles.subTextFileUpload]}>{translate('receipt.cameraAccess')}</Text>
<Button
variant={CONST.BUTTON_VARIANT.SUCCESS}
accessibilityLabel={translate('common.continue')}
style={[styles.p9, styles.pt5]}
onPress={askForPermissions}
>
<Button.Text>{translate('common.continue')}</Button.Text>
</Button>
</View>
)}
{cameraPermissionStatus === RESULTS.GRANTED && device == null && (
<View style={[styles.cameraView, styles.justifyContentCenter, styles.alignItemsCenter]}>
<ActivityIndicator
size={CONST.ACTIVITY_INDICATOR_SIZE.LARGE}
style={[styles.flex1]}
color={theme.textSupporting}
</PressableWithFeedback>

<PressableWithFeedback
role={CONST.ROLE.BUTTON}
accessibilityLabel={translate('receipt.flipCamera')}
style={[styles.alignItemsEnd, !canFlipCamera && styles.opacity0]}
disabled={cameraPermissionStatus !== RESULTS.GRANTED || !canFlipCamera}
onPress={() => setCameraPosition((prev) => (prev === 'back' ? 'front' : 'back'))}
sentryLabel={CONST.SENTRY_LABEL.ATTACHMENT_CAMERA.FLIP_CAMERA}
>
<Icon
height={variables.iconSizeMenuItem}
width={variables.iconSizeMenuItem}
src={lazyIcons.CameraFlip}
fill={theme.textSupporting}
/>
</View>
)}
{cameraPermissionStatus === RESULTS.GRANTED && device != null && (
<View style={[styles.cameraView, styles.alignItemsCenter]}>
<GestureDetector gesture={tapGesture}>
<View style={StyleUtils.getCameraViewfinderStyle(cameraAspectRatio, isLandscape)}>
<VisionCamera
ref={cameraRef}
device={device}
format={format ?? undefined}
style={styles.flex1}
zoom={device.neutralZoom}
photo
isActive={isVisible}
photoQualityBalance="quality"
onError={handleCameraError}
/>
<Animated.View style={[styles.cameraFocusIndicator, cameraFocusIndicatorAnimatedStyle]} />
</View>
</GestureDetector>
</View>
)}
</View>

<View style={[styles.flexRow, styles.justifyContentAround, styles.alignItemsCenter, styles.pv3]}>
<PressableWithFeedback
role={CONST.ROLE.BUTTON}
accessibilityLabel={translate('receipt.flash')}
style={[styles.alignItemsStart, !hasFlash && styles.opacity0]}
disabled={cameraPermissionStatus !== RESULTS.GRANTED || !hasFlash}
onPress={() => setFlash((prevFlash) => !prevFlash)}
sentryLabel="AttachmentCamera-Flash"
>
<Icon
height={variables.iconSizeMenuItem}
width={variables.iconSizeMenuItem}
src={flash ? lazyIcons.Bolt : lazyIcons.boltSlash}
fill={theme.textSupporting}
/>
</PressableWithFeedback>

<PressableWithFeedback
role={CONST.ROLE.BUTTON}
accessibilityLabel={translate('receipt.shutter')}
style={[styles.alignItemsCenter]}
onPress={capturePhoto}
sentryLabel="AttachmentCamera-Shutter"
>
<ImageSVG
contentFit="contain"
src={lazyIllustrations.Shutter}
width={CONST.RECEIPT.SHUTTER_SIZE}
height={CONST.RECEIPT.SHUTTER_SIZE}
/>
</PressableWithFeedback>

<PressableWithFeedback
role={CONST.ROLE.BUTTON}
accessibilityLabel={translate('receipt.flipCamera')}
style={[styles.alignItemsEnd, !canFlipCamera && styles.opacity0]}
disabled={cameraPermissionStatus !== RESULTS.GRANTED || !canFlipCamera}
onPress={() => setCameraPosition((prev) => (prev === 'back' ? 'front' : 'back'))}
sentryLabel="AttachmentCamera-FlipCamera"
>
<Icon
height={variables.iconSizeMenuItem}
width={variables.iconSizeMenuItem}
src={lazyIcons.CameraFlip}
fill={theme.textSupporting}
/>
</PressableWithFeedback>
</PressableWithFeedback>
</View>
</View>
</View>
</Modal>
Expand Down
18 changes: 18 additions & 0 deletions tests/ui/components/AttachmentCameraTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import AttachmentCamera from '@components/AttachmentPicker/AttachmentCamera';
import {LocaleContextProvider} from '@components/LocaleContextProvider';
import OnyxListItemProvider from '@components/OnyxListItemProvider';

import isInLandscapeMode from '@libs/isInLandscapeMode';

import type {CameraDevice} from 'react-native-vision-camera';

import React from 'react';
Expand All @@ -17,6 +19,9 @@ import waitForBatchedUpdatesWithAct from '../../utils/waitForBatchedUpdatesWithA
const mockTakePhoto = jest.fn(() => Promise.resolve({path: '/tmp/photos/shot.jpg', width: 3024, height: 4032}));
let mockPermissionStatus = 'granted';

jest.mock('@libs/isInLandscapeMode');
jest.mock('@expensify/react-native-hybrid-app', () => ({__esModule: true, default: {isHybridApp: jest.fn(() => false)}}));

jest.mock('@pages/iou/request/step/IOURequestStepScan/CameraPermission', () => ({
getCameraPermissionStatus: jest.fn(() => Promise.resolve(mockPermissionStatus)),
requestCameraPermission: jest.fn(() => Promise.resolve(mockPermissionStatus)),
Expand Down Expand Up @@ -50,6 +55,7 @@ const FRONT_DEVICE = createMock<CameraDevice>({id: 'front', position: 'front', h

const mockedUseCameraDevice = jest.mocked(useCameraDevice);
const mockedUseCameraDevices = jest.mocked(useCameraDevices);
const mockedIsInLandscapeMode = jest.mocked(isInLandscapeMode);

function renderCamera(props: Partial<React.ComponentProps<typeof AttachmentCamera>> = {}) {
const onCapture = jest.fn();
Expand Down Expand Up @@ -84,6 +90,7 @@ describe('AttachmentCamera', () => {
mockTakePhoto.mockResolvedValue({path: '/tmp/photos/shot.jpg', width: 3024, height: 4032});
mockedUseCameraDevice.mockReturnValue(BACK_DEVICE);
mockedUseCameraDevices.mockReturnValue([BACK_DEVICE, FRONT_DEVICE]);
mockedIsInLandscapeMode.mockReturnValue(false);
await act(async () => {
await Onyx.clear();
});
Expand Down Expand Up @@ -181,4 +188,15 @@ describe('AttachmentCamera', () => {
expect(mockTakePhoto).not.toHaveBeenCalled();
expect(onCapture).not.toHaveBeenCalled();
});

it('adapts layout for landscape orientation', async () => {
mockedIsInLandscapeMode.mockReturnValue(true);
renderCamera();
await waitForBatchedUpdatesWithAct();

const shutter = screen.getByLabelText(translateLocal('receipt.shutter'));
expect(shutter).toBeOnTheScreen();
const controlsContainer = shutter.parent;
expect(controlsContainer?.props.style).toEqual(expect.not.arrayContaining([expect.objectContaining({flexDirection: 'row'})]));
});
});
Loading