Skip to content

Commit 4ce9da3

Browse files
authored
Merge pull request #88344 from Expensify/claude-refactorIOURequestStepScanNative
Refactor: Split IOURequestStepScan/index.native.tsx into hooks and components
2 parents dfe7f59 + df26b3f commit 4ce9da3

6 files changed

Lines changed: 790 additions & 389 deletions

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import React from 'react';
2+
import {View} from 'react-native';
3+
import Button from '@components/Button';
4+
import ImageSVG from '@components/ImageSVG';
5+
import ScrollView from '@components/ScrollView';
6+
import Text from '@components/Text';
7+
import useLocalize from '@hooks/useLocalize';
8+
import useThemeStyles from '@hooks/useThemeStyles';
9+
import CONST from '@src/CONST';
10+
import type IconAsset from '@src/types/utils/IconAsset';
11+
12+
type CameraPermissionPromptProps = {
13+
/** Whether the device is currently in landscape orientation */
14+
isInLandscapeMode: boolean;
15+
16+
/** The hand illustration asset shown to prompt for camera access */
17+
handIllustration: IconAsset | undefined;
18+
19+
/** Callback fired when the continue button is pressed */
20+
onPress: () => void;
21+
};
22+
23+
function CameraPermissionPrompt({isInLandscapeMode, handIllustration, onPress}: CameraPermissionPromptProps) {
24+
const styles = useThemeStyles();
25+
const {translate} = useLocalize();
26+
27+
return (
28+
<ScrollView contentContainerStyle={styles.flexGrow1}>
29+
<View style={[styles.cameraView, isInLandscapeMode ? styles.permissionViewLandscape : styles.permissionView, styles.userSelectNone]}>
30+
<ImageSVG
31+
contentFit="contain"
32+
src={handIllustration}
33+
width={CONST.RECEIPT.HAND_ICON_WIDTH}
34+
height={CONST.RECEIPT.HAND_ICON_HEIGHT}
35+
style={styles.pb5}
36+
/>
37+
38+
<Text style={[styles.textFileUpload]}>{translate('receipt.takePhoto')}</Text>
39+
<Text style={[styles.subTextFileUpload]}>{translate('receipt.cameraAccess')}</Text>
40+
<Button
41+
success
42+
text={translate('common.continue')}
43+
accessibilityLabel={translate('common.continue')}
44+
style={[styles.p9, styles.pt5]}
45+
onPress={onPress}
46+
sentryLabel={CONST.SENTRY_LABEL.IOU_REQUEST_STEP.SCAN_SUBMIT_BUTTON}
47+
/>
48+
</View>
49+
</ScrollView>
50+
);
51+
}
52+
53+
export default CameraPermissionPrompt;
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import React from 'react';
2+
import type {RefObject} from 'react';
3+
import type {ViewStyle} from 'react-native';
4+
import {StyleSheet, View} from 'react-native';
5+
import type {GestureType} from 'react-native-gesture-handler';
6+
import {GestureDetector} from 'react-native-gesture-handler';
7+
import type {AnimatedStyle} from 'react-native-reanimated';
8+
import Animated from 'react-native-reanimated';
9+
import type {Camera, CameraDevice, CameraDeviceFormat} from 'react-native-vision-camera';
10+
import Icon from '@components/Icon';
11+
import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
12+
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
13+
import useLocalize from '@hooks/useLocalize';
14+
import useStyleUtils from '@hooks/useStyleUtils';
15+
import useTheme from '@hooks/useTheme';
16+
import useThemeStyles from '@hooks/useThemeStyles';
17+
import variables from '@styles/variables';
18+
import CONST from '@src/CONST';
19+
import NavigationAwareCamera from './NavigationAwareCamera/Camera';
20+
21+
type CameraViewportProps = {
22+
/** Ref to the underlying Camera instance */
23+
camera: RefObject<Camera | null>;
24+
25+
/** The active camera device descriptor */
26+
device: CameraDevice;
27+
28+
/** The selected camera format (resolution / FPS) */
29+
format: CameraDeviceFormat | undefined;
30+
31+
/** Target frames-per-second for the camera preview */
32+
fps: number;
33+
34+
/** Aspect ratio used to size the camera viewfinder */
35+
cameraAspectRatio: number | undefined;
36+
37+
/** Whether the device is currently in landscape orientation */
38+
isInLandscapeMode: boolean;
39+
40+
/** Gesture handler for tap-to-focus */
41+
tapGesture: GestureType;
42+
43+
/** Animated style driving the focus indicator ring */
44+
cameraFocusIndicatorAnimatedStyle: AnimatedStyle<ViewStyle>;
45+
46+
/** Animated style for the post-capture blink overlay */
47+
blinkStyle: AnimatedStyle<ViewStyle>;
48+
49+
/** Whether the attachment picker modal is currently open */
50+
isAttachmentPickerActive: boolean;
51+
52+
/** Whether a photo has been captured (forces camera inactive) */
53+
didCapturePhoto: boolean;
54+
55+
/** Callback fired when the camera finishes initializing */
56+
onInitialized: () => void;
57+
58+
/** Whether the multi-scan feature is available */
59+
canUseMultiScan: boolean;
60+
61+
/** Whether the camera flash is currently on */
62+
flash: boolean;
63+
64+
/** Whether the camera device supports flash */
65+
hasFlash: boolean;
66+
67+
/** Updater function to toggle flash state */
68+
setFlash: (updater: (prev: boolean) => boolean) => void;
69+
};
70+
71+
function CameraViewport({
72+
camera,
73+
device,
74+
format,
75+
fps,
76+
cameraAspectRatio,
77+
isInLandscapeMode,
78+
tapGesture,
79+
cameraFocusIndicatorAnimatedStyle,
80+
blinkStyle,
81+
isAttachmentPickerActive,
82+
didCapturePhoto,
83+
onInitialized,
84+
canUseMultiScan,
85+
flash,
86+
hasFlash,
87+
setFlash,
88+
}: CameraViewportProps) {
89+
const theme = useTheme();
90+
const styles = useThemeStyles();
91+
const StyleUtils = useStyleUtils();
92+
const {translate} = useLocalize();
93+
const lazyIcons = useMemoizedLazyExpensifyIcons(['Bolt']);
94+
95+
return (
96+
<View style={[styles.cameraView, styles.alignItemsCenter]}>
97+
<GestureDetector gesture={tapGesture}>
98+
<View style={StyleUtils.getCameraViewfinderStyle(cameraAspectRatio, isInLandscapeMode)}>
99+
<NavigationAwareCamera
100+
ref={camera}
101+
device={device}
102+
format={format}
103+
fps={fps}
104+
style={styles.flex1}
105+
zoom={device.neutralZoom}
106+
photo
107+
cameraTabIndex={1}
108+
forceInactive={isAttachmentPickerActive || didCapturePhoto}
109+
onInitialized={onInitialized}
110+
/>
111+
<Animated.View style={[styles.cameraFocusIndicator, cameraFocusIndicatorAnimatedStyle]} />
112+
<Animated.View
113+
pointerEvents="none"
114+
style={[StyleSheet.absoluteFill, StyleUtils.getBackgroundColorStyle(theme.appBG), blinkStyle, styles.zIndex10]}
115+
/>
116+
</View>
117+
</GestureDetector>
118+
{canUseMultiScan ? (
119+
<View style={[styles.flashButtonContainer, styles.primaryMediumIcon, flash && styles.bgGreenSuccess, !hasFlash && styles.opacity0]}>
120+
<PressableWithFeedback
121+
role={CONST.ROLE.BUTTON}
122+
accessibilityLabel={translate('receipt.flash')}
123+
sentryLabel={CONST.SENTRY_LABEL.REQUEST_STEP.SCAN.FLASH}
124+
disabled={!hasFlash}
125+
onPress={() => setFlash((prevFlash) => !prevFlash)}
126+
>
127+
<Icon
128+
height={variables.iconSizeSmall}
129+
width={variables.iconSizeSmall}
130+
src={lazyIcons.Bolt}
131+
fill={flash ? theme.white : theme.icon}
132+
/>
133+
</PressableWithFeedback>
134+
</View>
135+
) : null}
136+
</View>
137+
);
138+
}
139+
140+
export default CameraViewport;
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import React from 'react';
2+
import {View} from 'react-native';
3+
import {RESULTS} from 'react-native-permissions';
4+
import AttachmentPicker from '@components/AttachmentPicker';
5+
import Icon from '@components/Icon';
6+
import ImageSVG from '@components/ImageSVG';
7+
import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
8+
import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset';
9+
import useLocalize from '@hooks/useLocalize';
10+
import useTheme from '@hooks/useTheme';
11+
import useThemeStyles from '@hooks/useThemeStyles';
12+
import variables from '@styles/variables';
13+
import CONST from '@src/CONST';
14+
import type {FileObject} from '@src/types/utils/Attachment';
15+
16+
type ScannerControlsBarProps = {
17+
/** Whether the device is currently in landscape orientation */
18+
isInLandscapeMode: boolean;
19+
20+
/** Whether multi-scan mode is currently active */
21+
isMultiScanEnabled: boolean;
22+
23+
/** Whether the multi-scan feature is available */
24+
canUseMultiScan: boolean;
25+
26+
/** Whether the attachment picker should allow selecting multiple files */
27+
shouldAcceptMultipleFiles: boolean;
28+
29+
/** Current camera permission status from react-native-permissions */
30+
cameraPermissionStatus: string | null;
31+
32+
/** Whether the camera flash is currently on */
33+
flash: boolean;
34+
35+
/** Whether the camera device supports flash */
36+
hasFlash: boolean;
37+
38+
/** Updater function to toggle flash state */
39+
setFlash: (updater: (prev: boolean) => boolean) => void;
40+
41+
/** Sets whether the attachment picker modal is open */
42+
setIsAttachmentPickerActive: (value: boolean) => void;
43+
44+
/** Sets visibility of the full-screen loading indicator */
45+
setIsLoaderVisible: (value: boolean) => void;
46+
47+
/** Validates picked files and begins the receipt upload flow */
48+
validateFiles: (files: FileObject[], items?: DataTransferItem[]) => void;
49+
50+
/** Triggers photo capture from the camera */
51+
capturePhoto: () => void;
52+
53+
/** Toggles multi-scan mode on or off */
54+
toggleMultiScan: () => void;
55+
};
56+
57+
function ScannerControlsBar({
58+
isInLandscapeMode,
59+
isMultiScanEnabled,
60+
canUseMultiScan,
61+
shouldAcceptMultipleFiles,
62+
cameraPermissionStatus,
63+
flash,
64+
hasFlash,
65+
setFlash,
66+
setIsAttachmentPickerActive,
67+
setIsLoaderVisible,
68+
validateFiles,
69+
capturePhoto,
70+
toggleMultiScan,
71+
}: ScannerControlsBarProps) {
72+
const theme = useTheme();
73+
const styles = useThemeStyles();
74+
const {translate} = useLocalize();
75+
const lazyIllustrations = useMemoizedLazyIllustrations(['Shutter']);
76+
const lazyIcons = useMemoizedLazyExpensifyIcons(['Bolt', 'Gallery', 'ReceiptMultiple', 'boltSlash']);
77+
78+
return (
79+
<View style={[styles.justifyContentAround, styles.alignItemsCenter, styles.p3, !isInLandscapeMode && styles.flexRow]}>
80+
<AttachmentPicker
81+
onOpenPicker={() => {
82+
setIsAttachmentPickerActive(true);
83+
setIsLoaderVisible(true);
84+
}}
85+
fileLimit={shouldAcceptMultipleFiles ? CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT : 1}
86+
shouldValidateImage={false}
87+
>
88+
{({openPicker}) => (
89+
<PressableWithFeedback
90+
role={CONST.ROLE.BUTTON}
91+
accessibilityLabel={translate('receipt.gallery')}
92+
sentryLabel={shouldAcceptMultipleFiles ? CONST.SENTRY_LABEL.REQUEST_STEP.SCAN.CHOOSE_FILES : CONST.SENTRY_LABEL.REQUEST_STEP.SCAN.CHOOSE_FILE}
93+
style={[styles.alignItemsStart, isMultiScanEnabled && styles.opacity0]}
94+
onPress={() => {
95+
openPicker({
96+
onPicked: (data) => validateFiles(data),
97+
onCanceled: () => setIsLoaderVisible(false),
98+
// makes sure the loader is not visible anymore e.g. when there is an error while uploading a file
99+
onClosed: () => {
100+
setIsAttachmentPickerActive(false);
101+
setIsLoaderVisible(false);
102+
},
103+
});
104+
}}
105+
>
106+
<Icon
107+
height={variables.iconSizeMenuItem}
108+
width={variables.iconSizeMenuItem}
109+
src={lazyIcons.Gallery}
110+
fill={theme.textSupporting}
111+
/>
112+
</PressableWithFeedback>
113+
)}
114+
</AttachmentPicker>
115+
<PressableWithFeedback
116+
role={CONST.ROLE.BUTTON}
117+
accessibilityLabel={translate('receipt.shutter')}
118+
sentryLabel={CONST.SENTRY_LABEL.REQUEST_STEP.SCAN.SHUTTER}
119+
style={[styles.alignItemsCenter]}
120+
onPress={capturePhoto}
121+
>
122+
<ImageSVG
123+
contentFit="contain"
124+
src={lazyIllustrations.Shutter}
125+
width={CONST.RECEIPT.SHUTTER_SIZE}
126+
height={CONST.RECEIPT.SHUTTER_SIZE}
127+
/>
128+
</PressableWithFeedback>
129+
{canUseMultiScan ? (
130+
<PressableWithFeedback
131+
accessibilityRole="button"
132+
role={CONST.ROLE.BUTTON}
133+
accessibilityLabel={translate('receipt.multiScan')}
134+
sentryLabel={CONST.SENTRY_LABEL.REQUEST_STEP.SCAN.MULTI_SCAN}
135+
style={styles.alignItemsEnd}
136+
onPress={toggleMultiScan}
137+
>
138+
<Icon
139+
height={variables.iconSizeMenuItem}
140+
width={variables.iconSizeMenuItem}
141+
src={lazyIcons.ReceiptMultiple}
142+
fill={isMultiScanEnabled ? theme.iconMenu : theme.textSupporting}
143+
/>
144+
</PressableWithFeedback>
145+
) : (
146+
<PressableWithFeedback
147+
role={CONST.ROLE.BUTTON}
148+
accessibilityLabel={translate('receipt.flash')}
149+
sentryLabel={CONST.SENTRY_LABEL.REQUEST_STEP.SCAN.FLASH}
150+
style={[styles.alignItemsEnd, !hasFlash && styles.opacity0]}
151+
disabled={cameraPermissionStatus !== RESULTS.GRANTED || !hasFlash}
152+
onPress={() => setFlash((prevFlash) => !prevFlash)}
153+
>
154+
<Icon
155+
height={variables.iconSizeMenuItem}
156+
width={variables.iconSizeMenuItem}
157+
src={flash ? lazyIcons.Bolt : lazyIcons.boltSlash}
158+
fill={theme.textSupporting}
159+
/>
160+
</PressableWithFeedback>
161+
)}
162+
</View>
163+
);
164+
}
165+
166+
export default ScannerControlsBar;

0 commit comments

Comments
 (0)