diff --git a/android/gradle.properties b/android/gradle.properties index 0546090c6788..0de47ef7d184 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -51,3 +51,7 @@ MYAPP_UPLOAD_STORE_FILE=my-upload-key.keystore # Key Information MYAPP_UPLOAD_KEY_ALIAS=ReactNativeChat-Key-Alias + +# Disable Frame Processors for VisionCamera. +# We might want to re-enable them if we need QR code scanning or other frame processing features (maybe in VisionCamera V3) +disableFrameProcessors=true diff --git a/assets/images/hand.svg b/assets/images/hand.svg new file mode 100644 index 000000000000..e9a56d260ed0 --- /dev/null +++ b/assets/images/hand.svg @@ -0,0 +1,196 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/receipt-doc.png b/assets/images/receipt-doc.png new file mode 100644 index 000000000000..773bfaed73ad Binary files /dev/null and b/assets/images/receipt-doc.png differ diff --git a/assets/images/receipt-generic.png b/assets/images/receipt-generic.png new file mode 100644 index 000000000000..1aabe854617d Binary files /dev/null and b/assets/images/receipt-generic.png differ diff --git a/assets/images/receipt-html.png b/assets/images/receipt-html.png new file mode 100644 index 000000000000..5cf8d585b21f Binary files /dev/null and b/assets/images/receipt-html.png differ diff --git a/assets/images/receipt-svg.png b/assets/images/receipt-svg.png new file mode 100644 index 000000000000..130c331dd8c9 Binary files /dev/null and b/assets/images/receipt-svg.png differ diff --git a/assets/images/receipt-upload.svg b/assets/images/receipt-upload.svg new file mode 100644 index 000000000000..813aaac51f5b --- /dev/null +++ b/assets/images/receipt-upload.svg @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/shutter.svg b/assets/images/shutter.svg new file mode 100644 index 000000000000..e4dadcea8089 --- /dev/null +++ b/assets/images/shutter.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 72213bc9432a..fd408081faf5 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -563,6 +563,8 @@ PODS: - React-Core - react-native-netinfo (9.3.10): - React-Core + - react-native-pager-view (6.2.0): + - React-Core - react-native-pdf (6.6.2): - React-Core - react-native-performance (4.0.0): @@ -780,6 +782,10 @@ PODS: - libwebp (~> 1.0) - SDWebImage/Core (~> 5.10) - SocketRocket (0.6.1) + - VisionCamera (2.15.4): + - React + - React-callinvoker + - React-Core - Yoga (1.14.0) - YogaKit (1.18.1): - Yoga (~> 1.14) @@ -846,6 +852,7 @@ DEPENDENCIES: - react-native-image-picker (from `../node_modules/react-native-image-picker`) - react-native-key-command (from `../node_modules/react-native-key-command`) - "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)" + - react-native-pager-view (from `../node_modules/react-native-pager-view`) - react-native-pdf (from `../node_modules/react-native-pdf`) - react-native-performance (from `../node_modules/react-native-performance`) - react-native-plaid-link-sdk (from `../node_modules/react-native-plaid-link-sdk`) @@ -890,6 +897,7 @@ DEPENDENCIES: - RNReanimated (from `../node_modules/react-native-reanimated`) - RNScreens (from `../node_modules/react-native-screens`) - RNSVG (from `../node_modules/react-native-svg`) + - VisionCamera (from `../node_modules/react-native-vision-camera`) - Yoga (from `../node_modules/react-native/ReactCommon/yoga`) SPEC REPOS: @@ -1007,6 +1015,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-key-command" react-native-netinfo: :path: "../node_modules/@react-native-community/netinfo" + react-native-pager-view: + :path: "../node_modules/react-native-pager-view" react-native-pdf: :path: "../node_modules/react-native-pdf" react-native-performance: @@ -1095,6 +1105,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-screens" RNSVG: :path: "../node_modules/react-native-svg" + VisionCamera: + :path: "../node_modules/react-native-vision-camera" Yoga: :path: "../node_modules/react-native/ReactCommon/yoga" @@ -1168,6 +1180,7 @@ SPEC CHECKSUMS: react-native-image-picker: c33d4e79f0a14a2b66e5065e14946ae63749660b react-native-key-command: c2645ec01eb1fa664606c09480c05cb4220ef67b react-native-netinfo: ccbe1085dffd16592791d550189772e13bf479e2 + react-native-pager-view: 0ccb8bf60e2ebd38b1f3669fa3650ecce81db2df react-native-pdf: 33c622cbdf776a649929e8b9d1ce2d313347c4fa react-native-performance: 224bd53e6a835fda4353302cf891d088a0af7406 react-native-plaid-link-sdk: 9eb0f71dad94b3bdde649c7a384cba93024af46c @@ -1215,9 +1228,10 @@ SPEC CHECKSUMS: SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d SDWebImageWebPCoder: 908b83b6adda48effe7667cd2b7f78c897e5111d SocketRocket: f32cd54efbe0f095c4d7594881e52619cfe80b17 + VisionCamera: d3ec8883417a6a4a0e3a6ba37d81d22db7611601 Yoga: 65286bb6a07edce5e4fe8c90774da977ae8fc009 YogaKit: f782866e155069a2cca2517aafea43200b01fd5a PODFILE CHECKSUM: bc8161c6bfffeec6e6eaf84be18de5041ddcacf6 -COCOAPODS: 1.12.1 +COCOAPODS: 1.11.3 diff --git a/package-lock.json b/package-lock.json index 140155aff25e..6cdec260411e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "@react-native-firebase/crashlytics": "^12.3.0", "@react-native-firebase/perf": "^12.3.0", "@react-native-picker/picker": "^2.4.3", + "@react-navigation/material-top-tabs": "^6.6.3", "@react-navigation/native": "6.1.6", "@react-navigation/stack": "6.3.16", "@react-ng/bounds-observer": "^0.2.1", @@ -79,6 +80,7 @@ "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", "react-native-onyx": "1.0.52", + "react-native-pager-view": "^6.2.0", "react-native-pdf": "^6.6.2", "react-native-performance": "^4.0.0", "react-native-permissions": "^3.0.1", @@ -91,7 +93,9 @@ "react-native-safe-area-context": "4.4.1", "react-native-screens": "3.21.0", "react-native-svg": "^13.9.0", + "react-native-tab-view": "^3.5.2", "react-native-view-shot": "^3.6.0", + "react-native-vision-camera": "^2.15.4", "react-native-web-lottie": "^1.4.4", "react-native-webview": "^11.17.2", "react-pdf": "^6.2.2", @@ -8703,6 +8707,22 @@ "react": "*" } }, + "node_modules/@react-navigation/material-top-tabs": { + "version": "6.6.3", + "resolved": "https://registry.npmjs.org/@react-navigation/material-top-tabs/-/material-top-tabs-6.6.3.tgz", + "integrity": "sha512-7rbBUUvVSKD8jV/a7iV2BTSQ83G7W8grGSwBNojdeXdeZpsUa+wmmKnPtBFhdPv7DDQp7nzAYRx6RCOPtjZSCw==", + "dependencies": { + "color": "^4.2.3", + "warn-once": "^0.1.0" + }, + "peerDependencies": { + "@react-navigation/native": "^6.0.0", + "react": "*", + "react-native": "*", + "react-native-pager-view": ">= 4.0.0", + "react-native-tab-view": ">= 3.0.0" + } + }, "node_modules/@react-navigation/native": { "version": "6.1.6", "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-6.1.6.tgz", @@ -38093,6 +38113,15 @@ } } }, + "node_modules/react-native-pager-view": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/react-native-pager-view/-/react-native-pager-view-6.2.0.tgz", + "integrity": "sha512-pf9OnL/Tkr+5s4Gjmsn7xh91PtJLDa6qxYa/bmtUhd/+s4cQdWQ8DIFoOFghwZIHHHwVdWtoXkp6HtpjN+r20g==", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-pdf": { "version": "6.6.2", "license": "MIT", @@ -38285,6 +38314,19 @@ "react-native-svg": ">=12.0.0" } }, + "node_modules/react-native-tab-view": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/react-native-tab-view/-/react-native-tab-view-3.5.2.tgz", + "integrity": "sha512-nE5WqjbeEPsWQx4mtz81QGVvgHRhujTNIIZiMCx3Bj6CBFDafbk7XZp9ocmtzXUQaZ4bhtVS43R4FIiR4LboJw==", + "dependencies": { + "use-latest-callback": "^0.1.5" + }, + "peerDependencies": { + "react": "*", + "react-native": "*", + "react-native-pager-view": "*" + } + }, "node_modules/react-native-view-shot": { "version": "3.6.0", "license": "MIT", @@ -38293,6 +38335,15 @@ "react-native": "*" } }, + "node_modules/react-native-vision-camera": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/react-native-vision-camera/-/react-native-vision-camera-2.15.4.tgz", + "integrity": "sha512-SJXSWH1pu4V3Kj4UuX/vSgOxc9d5wb5+nHqBHd+5iUtVyVLEp0F6Jbbaha7tDoU+kUBwonhlwr2o8oV6NZ7Ibg==", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-web": { "version": "0.18.12", "license": "MIT", @@ -51134,6 +51185,15 @@ "stacktrace-parser": "^0.1.10" } }, + "@react-navigation/material-top-tabs": { + "version": "6.6.3", + "resolved": "https://registry.npmjs.org/@react-navigation/material-top-tabs/-/material-top-tabs-6.6.3.tgz", + "integrity": "sha512-7rbBUUvVSKD8jV/a7iV2BTSQ83G7W8grGSwBNojdeXdeZpsUa+wmmKnPtBFhdPv7DDQp7nzAYRx6RCOPtjZSCw==", + "requires": { + "color": "^4.2.3", + "warn-once": "^0.1.0" + } + }, "@react-navigation/native": { "version": "6.1.6", "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-6.1.6.tgz", @@ -70948,6 +71008,12 @@ "underscore": "^1.13.1" } }, + "react-native-pager-view": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/react-native-pager-view/-/react-native-pager-view-6.2.0.tgz", + "integrity": "sha512-pf9OnL/Tkr+5s4Gjmsn7xh91PtJLDa6qxYa/bmtUhd/+s4cQdWQ8DIFoOFghwZIHHHwVdWtoXkp6HtpjN+r20g==", + "requires": {} + }, "react-native-pdf": { "version": "6.6.2", "requires": { @@ -71068,10 +71134,24 @@ "path-dirname": "^1.0.2" } }, + "react-native-tab-view": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/react-native-tab-view/-/react-native-tab-view-3.5.2.tgz", + "integrity": "sha512-nE5WqjbeEPsWQx4mtz81QGVvgHRhujTNIIZiMCx3Bj6CBFDafbk7XZp9ocmtzXUQaZ4bhtVS43R4FIiR4LboJw==", + "requires": { + "use-latest-callback": "^0.1.5" + } + }, "react-native-view-shot": { "version": "3.6.0", "requires": {} }, + "react-native-vision-camera": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/react-native-vision-camera/-/react-native-vision-camera-2.15.4.tgz", + "integrity": "sha512-SJXSWH1pu4V3Kj4UuX/vSgOxc9d5wb5+nHqBHd+5iUtVyVLEp0F6Jbbaha7tDoU+kUBwonhlwr2o8oV6NZ7Ibg==", + "requires": {} + }, "react-native-web": { "version": "0.18.12", "peer": true, diff --git a/package.json b/package.json index 478217483dab..f10551edfb86 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "@react-native-firebase/crashlytics": "^12.3.0", "@react-native-firebase/perf": "^12.3.0", "@react-native-picker/picker": "^2.4.3", + "@react-navigation/material-top-tabs": "^6.6.3", "@react-navigation/native": "6.1.6", "@react-navigation/stack": "6.3.16", "@react-ng/bounds-observer": "^0.2.1", @@ -118,6 +119,7 @@ "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", "react-native-onyx": "1.0.52", + "react-native-pager-view": "^6.2.0", "react-native-pdf": "^6.6.2", "react-native-performance": "^4.0.0", "react-native-permissions": "^3.0.1", @@ -130,7 +132,9 @@ "react-native-safe-area-context": "4.4.1", "react-native-screens": "3.21.0", "react-native-svg": "^13.9.0", + "react-native-tab-view": "^3.5.2", "react-native-view-shot": "^3.6.0", + "react-native-vision-camera": "^2.15.4", "react-native-web-lottie": "^1.4.4", "react-native-webview": "^11.17.2", "react-pdf": "^6.2.2", diff --git a/patches/react-native-vision-camera+2.15.4.patch b/patches/react-native-vision-camera+2.15.4.patch new file mode 100644 index 000000000000..0c80d6a8ce55 --- /dev/null +++ b/patches/react-native-vision-camera+2.15.4.patch @@ -0,0 +1,13 @@ +diff --git a/node_modules/react-native-vision-camera/ios/Frame Processor/FrameProcessorRuntimeManager.mm b/node_modules/react-native-vision-camera/ios/Frame Processor/FrameProcessorRuntimeManager.mm +index 3841b20..687ea94 100644 +--- a/node_modules/react-native-vision-camera/ios/Frame Processor/FrameProcessorRuntimeManager.mm ++++ b/node_modules/react-native-vision-camera/ios/Frame Processor/FrameProcessorRuntimeManager.mm +@@ -19,6 +19,8 @@ + #import + #import + ++#define VISION_CAMERA_DISABLE_FRAME_PROCESSORS 1 ++ + #ifndef VISION_CAMERA_DISABLE_FRAME_PROCESSORS + #if __has_include() + #if __has_include() diff --git a/src/CONST.js b/src/CONST.js index 06bc1873431f..4f304f08e320 100755 --- a/src/CONST.js +++ b/src/CONST.js @@ -479,6 +479,13 @@ const CONST = { REPORT: 'report', PERSONAL_DETAIL: 'personalDetail', }, + RECEIPT: { + ICON_SIZE: 164, + PERMISSION_AUTHORIZED: 'authorized', + HAND_ICON_HEIGHT: 152, + HAND_ICON_WIDTH: 200, + SHUTTER_SIZE: 90, + }, REPORT: { MAXIMUM_PARTICIPANTS: 8, SPLIT_REPORTID: '-2', @@ -1054,6 +1061,12 @@ const CONST = { DELETE: 'delete', }, AMOUNT_MAX_LENGTH: 10, + FILE_TYPES: { + HTML: 'html', + DOC: 'doc', + DOCX: 'docx', + SVG: 'svg', + }, }, GROWL: { @@ -1106,6 +1119,10 @@ const CONST = { ICON_TYPE_AVATAR: 'avatar', ICON_TYPE_WORKSPACE: 'workspace', + ACTIVITY_INDICATOR_SIZE: { + LARGE: 'large', + }, + AVATAR_SIZE: { LARGE: 'large', MEDIUM: 'medium', @@ -2542,6 +2559,11 @@ const CONST = { TRANSLATION_KEYS: { ATTACHMENT: 'common.attachment', }, + TAB: { + RECEIPT_TAB_ID: 'ReceiptTab', + MANUAL: 'manual', + SCAN: 'scan', + }, }; export default CONST; diff --git a/src/ONYXKEYS.js b/src/ONYXKEYS.js index 276e128603d5..0d27ba64dedf 100755 --- a/src/ONYXKEYS.js +++ b/src/ONYXKEYS.js @@ -238,4 +238,10 @@ export default { // Experimental memory only Onyx mode flag IS_USING_MEMORY_ONLY_KEYS: 'isUsingMemoryOnlyKeys', + + // Manual request tab selector + SELECTED_TAB: 'selectedTab', + + // Receipt upload modal + RECEIPT_MODAL: 'receiptModal', }; diff --git a/src/ROUTES.js b/src/ROUTES.js index 92d11f440584..a6e21baddf59 100644 --- a/src/ROUTES.js +++ b/src/ROUTES.js @@ -85,6 +85,8 @@ export default { MONEY_REQUEST_CONFIRMATION: ':iouType/new/confirmation/:reportID?', MONEY_REQUEST_CURRENCY: ':iouType/new/currency/:reportID?', MONEY_REQUEST_DESCRIPTION: ':iouType/new/description/:reportID?', + MONEY_REQUEST_MANUAL_TAB: ':iouType/new/:reportID?/manual', + MONEY_REQUEST_SCAN_TAB: ':iouType/new/:reportID?/scan', IOU_SEND_ADD_BANK_ACCOUNT: `${IOU_SEND}/add-bank-account`, IOU_SEND_ADD_DEBIT_CARD: `${IOU_SEND}/add-debit-card`, IOU_SEND_ENABLE_PAYMENTS: `${IOU_SEND}/enable-payments`, diff --git a/src/components/AttachmentModal.js b/src/components/AttachmentModal.js index bb648c155840..2570de52cb30 100755 --- a/src/components/AttachmentModal.js +++ b/src/components/AttachmentModal.js @@ -136,6 +136,7 @@ function AttachmentModal(props) { // eslint-disable-next-line react-hooks/exhaustive-deps [props.translate], ); + /** * Download the currently viewed attachment. */ @@ -176,6 +177,7 @@ function AttachmentModal(props) { const closeConfirmModal = useCallback(() => { setIsAttachmentInvalid(false); }, []); + /** * @param {Object} _file * @returns {Boolean} @@ -211,6 +213,7 @@ function AttachmentModal(props) { // eslint-disable-next-line react-hooks/exhaustive-deps [props.translate], ); + /** * @param {Object} _file */ diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js index 5bcc643c54e7..e0a590940cbc 100755 --- a/src/components/MoneyRequestConfirmationList.js +++ b/src/components/MoneyRequestConfirmationList.js @@ -2,13 +2,12 @@ import React, {useState, useCallback, useMemo} from 'react'; import PropTypes from 'prop-types'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; +import Str from 'expensify-common/lib/str'; import styles from '../styles/styles'; import * as ReportUtils from '../libs/ReportUtils'; import * as OptionsListUtils from '../libs/OptionsListUtils'; import OptionsSelector from './OptionsSelector'; import ONYXKEYS from '../ONYXKEYS'; -import withLocalize, {withLocalizePropTypes} from './withLocalize'; -import withWindowDimensions, {windowDimensionsPropTypes} from './withWindowDimensions'; import compose from '../libs/compose'; import CONST from '../CONST'; import ButtonWithDropdownMenu from './ButtonWithDropdownMenu'; @@ -21,6 +20,13 @@ import MenuItemWithTopDescription from './MenuItemWithTopDescription'; import Navigation from '../libs/Navigation/Navigation'; import optionPropTypes from './optionPropTypes'; import * as CurrencyUtils from '../libs/CurrencyUtils'; +import Image from './Image'; +import ReceiptHTML from '../../assets/images/receipt-html.png'; +import ReceiptDoc from '../../assets/images/receipt-doc.png'; +import ReceiptGeneric from '../../assets/images/receipt-generic.png'; +import ReceiptSVG from '../../assets/images/receipt-svg.png'; +import * as FileUtils from '../libs/fileDownload/FileUtils'; +import useLocalize from '../hooks/useLocalize'; const propTypes = { /** Callback to inform parent modal of success */ @@ -62,14 +68,8 @@ const propTypes = { /** Depending on expense report or personal IOU report, respective bank account route */ bankAccountRoute: PropTypes.string, - ...windowDimensionsPropTypes, - - ...withLocalizePropTypes, - ...withCurrentUserPersonalDetailsPropTypes, - /* Onyx Props */ - /** Current user session */ session: PropTypes.shape({ email: PropTypes.string.isRequired, @@ -80,6 +80,12 @@ const propTypes = { /** The reportID of the request */ reportID: PropTypes.string, + + /** File path of the receipt */ + receiptPath: PropTypes.string, + + /** File source of the receipt */ + receiptSource: PropTypes.string, }; const defaultProps = { @@ -97,12 +103,15 @@ const defaultProps = { policyID: '', reportID: '', ...withCurrentUserPersonalDetailsDefaultProps, + receiptPath: '', + receiptSource: '', }; function MoneyRequestConfirmationList(props) { // Destructure functions from props to pass it as a dependecy to useCallback/useMemo hooks. // Prop functions pass props itself as a "this" value to the function which means they change every time props change. - const {translate, onSendMoney, onConfirm, onSelectParticipant} = props; + const {onSendMoney, onConfirm, onSelectParticipant} = props; + const {translate} = useLocalize(); /** * Returns the participants with amount @@ -120,16 +129,20 @@ function MoneyRequestConfirmationList(props) { const [didConfirm, setDidConfirm] = useState(false); const splitOrRequestOptions = useMemo(() => { - const text = translate(props.hasMultipleParticipants ? 'iou.splitAmount' : 'iou.requestAmount', { - amount: CurrencyUtils.convertToDisplayString(props.iouAmount, props.iouCurrencyCode), - }); + let text; + if (props.receiptPath) { + text = translate('iou.request'); + } else { + const translationKey = props.hasMultipleParticipants ? 'iou.splitAmount' : 'iou.requestAmount'; + text = translate(translationKey, {amount: CurrencyUtils.convertToDisplayString(props.iouAmount, props.iouCurrencyCode)}); + } return [ { text: text[0].toUpperCase() + text.slice(1), value: props.hasMultipleParticipants ? CONST.IOU.MONEY_REQUEST_TYPE.SPLIT : CONST.IOU.MONEY_REQUEST_TYPE.REQUEST, }, ]; - }, [props.hasMultipleParticipants, props.iouAmount, props.iouCurrencyCode, translate]); + }, [props.hasMultipleParticipants, props.iouAmount, props.receiptPath, props.iouCurrencyCode, translate]); const selectedParticipants = useMemo(() => _.filter(props.selectedParticipants, (participant) => participant.selected), [props.selectedParticipants]); const payeePersonalDetails = useMemo(() => props.payeePersonalDetails || props.currentUserPersonalDetails, [props.payeePersonalDetails, props.currentUserPersonalDetails]); @@ -287,6 +300,36 @@ function MoneyRequestConfirmationList(props) { ); }, [confirm, props.selectedParticipants, props.bankAccountRoute, props.iouCurrencyCode, props.iouType, props.isReadOnly, props.policyID, selectedParticipants, splitOrRequestOptions]); + /** + * Grab the appropriate image URI based on file type + * + * @param {String} receiptPath + * @param {String} receiptSource + * @returns {*} + */ + const getImageURI = (receiptPath, receiptSource) => { + const {fileExtension} = FileUtils.splitExtensionFromFileName(receiptSource); + const isReceiptImage = Str.isImage(props.receiptSource); + + if (isReceiptImage) { + return receiptPath; + } + + if (fileExtension === CONST.IOU.FILE_TYPES.HTML) { + return ReceiptHTML; + } + + if (fileExtension === CONST.IOU.FILE_TYPES.DOC || fileExtension === CONST.IOU.FILE_TYPES.DOCX) { + return ReceiptDoc; + } + + if (fileExtension === CONST.IOU.FILE_TYPES.SVG) { + return ReceiptSVG; + } + + return ReceiptGeneric; + }; + return ( - Navigation.navigate(ROUTES.getMoneyRequestAmountRoute(props.iouType, props.reportID))} - style={[styles.moneyRequestMenuItem, styles.mt2]} - titleStyle={styles.moneyRequestConfirmationAmount} - disabled={didConfirm || props.isReadOnly} - /> + {!_.isEmpty(props.receiptPath) ? ( + + ) : ( + Navigation.navigate(ROUTES.getMoneyRequestAmountRoute(props.iouType, props.reportID))} + style={[styles.moneyRequestMenuItem, styles.mt2]} + titleStyle={styles.moneyRequestConfirmationAmount} + disabled={didConfirm || props.isReadOnly} + /> + )} {}, +}; + +const getIcon = (route) => (route === CONST.TAB.MANUAL ? Expensicons.Pencil : Expensicons.Receipt); + +function TabSelector({state, navigation, onTabPress}) { + const {translate} = useLocalize(); + return ( + + {_.map(state.routes, (route, index) => { + const isFocused = state.index === index; + + const onPress = () => { + const event = navigation.emit({ + type: 'tabPress', + target: route.key, + canPreventDefault: true, + }); + + if (!isFocused && !event.defaultPrevented) { + // The `merge: true` option makes sure that the params inside the tab screen are preserved + navigation.navigate({name: route.name, merge: true}); + } + + onTabPress(route.name); + }; + + return ( + + ); + })} + + ); +} + +TabSelector.propTypes = propTypes; +TabSelector.defaultProps = defaultProps; +TabSelector.displayName = 'TabSelector'; + +export default TabSelector; diff --git a/src/components/TabSelector/TabSelectorItem.js b/src/components/TabSelector/TabSelectorItem.js new file mode 100644 index 000000000000..cea59bc2ee65 --- /dev/null +++ b/src/components/TabSelector/TabSelectorItem.js @@ -0,0 +1,51 @@ +import {Text} from 'react-native'; +import React from 'react'; +import PropTypes from 'prop-types'; +import Icon from '../Icon'; +import themeColors from '../../styles/themes/default'; +import styles from '../../styles/styles'; +import PressableWithFeedback from '../Pressable/PressableWithFeedback'; + +const propTypes = { + /** Function to call when onPress */ + onPress: PropTypes.func, + + /** Icon to display on tab */ + icon: PropTypes.func, + + /** True if tab is the selected item */ + isSelected: PropTypes.bool, + + /** Title of the tab */ + title: PropTypes.string, +}; + +const defaultProps = { + onPress: () => {}, + icon: () => {}, + isSelected: false, + title: '', +}; + +function TabSelectorItem(props) { + return ( + + + {props.title} + + ); +} + +TabSelectorItem.propTypes = propTypes; +TabSelectorItem.defaultProps = defaultProps; +TabSelectorItem.displayName = 'TabSelectorItem'; + +export default TabSelectorItem; diff --git a/src/components/participantPropTypes.js b/src/components/participantPropTypes.js index a54d82eeb0ed..75f575b1c3dd 100644 --- a/src/components/participantPropTypes.js +++ b/src/components/participantPropTypes.js @@ -15,4 +15,13 @@ export default PropTypes.shape({ /** First Name of the participant */ firstName: PropTypes.string, + + /** True if the report is a Policy Expense chat */ + isPolicyExpenseChat: PropTypes.bool, + + /** True if the policy expense chat is owned by this user */ + isOwnPolicyExpenseChat: PropTypes.bool, + + /** Whether the participant is selected */ + selected: PropTypes.bool, }); diff --git a/src/hooks/useDragAndDrop.js b/src/hooks/useDragAndDrop.js index 98df70085a72..bc8ab517731b 100644 --- a/src/hooks/useDragAndDrop.js +++ b/src/hooks/useDragAndDrop.js @@ -58,6 +58,7 @@ export default function useDragAndDrop({dropZone, onDrop = () => {}, shouldAllow } event.preventDefault(); + event.stopPropagation(); switch (event.type) { case DRAG_OVER_EVENT: diff --git a/src/languages/en.js b/src/languages/en.js index ba4aca6b4a2a..563e1f9de0c0 100755 --- a/src/languages/en.js +++ b/src/languages/en.js @@ -343,10 +343,32 @@ export default { listOfChatMessages: 'List of chat messages', listOfChats: 'List of chats', }, + tabSelector: { + manual: 'Manual', + scan: 'Scan', + }, + receipt: { + upload: 'Upload receipt', + dragReceiptBeforeEmail: 'Drag a receipt onto this page, forward a receipt to ', + dragReceiptAfterEmail: ' or choose a file to upload below.', + chooseReceipt: 'Choose a receipt to upload or forward a receipt to ', + chooseFile: 'Choose File', + givePermission: 'Give permission', + takePhoto: 'Take a photo', + cameraAccess: 'Camera access is required to take pictures of receipts.', + cameraErrorTitle: 'Camera Error', + cameraErrorMessage: 'An error occurred while taking a photo, please try again', + dropTitle: 'Let it go', + dropMessage: 'Drop your file here', + flash: 'flash', + shutter: 'shutter', + gallery: 'gallery', + }, iou: { amount: 'Amount', cash: 'Cash', split: 'Split', + request: 'Request', participants: 'Participants', splitBill: 'Split bill', requestMoney: 'Request money', diff --git a/src/languages/es.js b/src/languages/es.js index a3c983ac6c10..91076346a5e7 100644 --- a/src/languages/es.js +++ b/src/languages/es.js @@ -342,10 +342,32 @@ export default { listOfChatMessages: 'Lista de mensajes del chat', listOfChats: 'lista de chats', }, + tabSelector: { + manual: 'Manual', + scan: 'Escanear', + }, + receipt: { + upload: 'Subir recibo', + dragReceiptBeforeEmail: 'Arrastra un recibo a esta página, reenvíalo a ', + dragReceiptAfterEmail: ' o elije un archivo para subir a continuación.', + chooseReceipt: 'Elige un recibo para subir o reenvía un recibo a ', + chooseFile: 'Elegir archivo', + givePermission: 'Permitir', + takePhoto: 'Haz una foto', + cameraAccess: 'Se requiere acceso a la cámara para hacer fotos de los recibos.', + cameraErrorTitle: 'Error en la cámara', + cameraErrorMessage: 'Se produjo un error al hacer una foto, Por favor, inténtalo de nuevo.', + dropTitle: 'Suéltalo', + dropMessage: 'Suelta tu archivo aquí', + flash: 'flash', + shutter: 'obturador', + gallery: 'galería', + }, iou: { amount: 'Importe', cash: 'Efectivo', split: 'Dividir', + request: 'Solicitar', participants: 'Participantes', splitBill: 'Dividir factura', requestMoney: 'Pedir dinero', diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js index fcc4b3aaaa3e..31d2057ddd5a 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js @@ -36,14 +36,14 @@ function createModalStackNavigator(screens) { const MoneyRequestModalStackNavigator = createModalStackNavigator([ { getComponent: () => { - const MoneyRequestAmountPage = require('../../../pages/iou/steps/MoneyRequestAmountPage').default; - return MoneyRequestAmountPage; + const MoneyRequestSelectorPage = require('../../../pages/iou/MoneyRequestSelectorPage').default; + return MoneyRequestSelectorPage; }, name: 'Money_Request', }, { getComponent: () => { - const MoneyRequestEditAmountPage = require('../../../pages/iou/steps/MoneyRequestAmountPage').default; + const MoneyRequestEditAmountPage = require('../../../pages/iou/steps/MoneyRequestAmount').default; return MoneyRequestEditAmountPage; }, name: 'Money_Request_Amount', diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.js b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.js index 7d6d4cb2709c..53e2120f4c21 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.js +++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.js @@ -6,7 +6,7 @@ import RHPScreenOptions from '../RHPScreenOptions'; const Stack = createStackNavigator(); -function RigthModalNavigator() { +function RightModalNavigator() { return ( { + const state = event.data.state; + const index = state.index; + const routeNames = state.routeNames; + Tab.setSelectedTab(id, routeNames[index]); + }, + ...(rest.screenListeners || {}), + }} + > + {children} + + ); +} + +OnyxTabNavigator.defaultProps = defaultProps; +OnyxTabNavigator.propTypes = propTypes; +OnyxTabNavigator.displayName = 'OnyxTabNavigator'; + +export default withOnyx({ + selectedTab: { + key: ({id}) => `${ONYXKEYS.SELECTED_TAB}_${id}`, + }, +})(OnyxTabNavigator); diff --git a/src/libs/Navigation/linkingConfig.js b/src/libs/Navigation/linkingConfig.js index 44e040ccb102..04a72b15dd18 100644 --- a/src/libs/Navigation/linkingConfig.js +++ b/src/libs/Navigation/linkingConfig.js @@ -290,7 +290,20 @@ export default { }, MoneyRequest: { screens: { - Money_Request: ROUTES.MONEY_REQUEST, + Money_Request: { + path: ROUTES.MONEY_REQUEST, + exact: true, + screens: { + manual: { + path: ROUTES.MONEY_REQUEST_MANUAL_TAB, + exact: true, + }, + scan: { + path: ROUTES.MONEY_REQUEST_SCAN_TAB, + exact: true, + }, + }, + }, Money_Request_Amount: ROUTES.MONEY_REQUEST_AMOUNT, Money_Request_Participants: ROUTES.MONEY_REQUEST_PARTICIPANTS, Money_Request_Confirmation: ROUTES.MONEY_REQUEST_CONFIRMATION, diff --git a/src/libs/ReceiptUtils.js b/src/libs/ReceiptUtils.js new file mode 100644 index 000000000000..b8ec54c0e899 --- /dev/null +++ b/src/libs/ReceiptUtils.js @@ -0,0 +1,28 @@ +import lodashGet from 'lodash/get'; +import _ from 'underscore'; +import * as FileUtils from './fileDownload/FileUtils'; +import CONST from '../CONST'; +import Receipt from './actions/Receipt'; +import * as Localize from './Localize'; + +const validateReceipt = (file) => { + const {fileExtension} = FileUtils.splitExtensionFromFileName(lodashGet(file, 'name', '')); + if (_.contains(CONST.API_ATTACHMENT_VALIDATIONS.UNALLOWED_EXTENSIONS, fileExtension.toLowerCase())) { + Receipt.setUploadReceiptError(true, Localize.translateLocal('attachmentPicker.wrongFileType'), Localize.translateLocal('attachmentPicker.notAllowedExtension')); + return false; + } + + if (lodashGet(file, 'size', 0) > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) { + Receipt.setUploadReceiptError(true, Localize.translateLocal('attachmentPicker.attachmentTooLarge'), Localize.translateLocal('attachmentPicker.sizeExceeded')); + return false; + } + + if (lodashGet(file, 'size', 0) < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) { + Receipt.setUploadReceiptError(true, Localize.translateLocal('attachmentPicker.attachmentTooSmall'), Localize.translateLocal('attachmentPicker.sizeNotMet')); + return false; + } + + return true; +}; + +export default {validateReceipt}; diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js index 5660bc31a880..9cd28bccc6c7 100644 --- a/src/libs/actions/IOU.js +++ b/src/libs/actions/IOU.js @@ -68,6 +68,8 @@ function resetMoneyRequestInfo(id = '') { currency: lodashGet(currentUserPersonalDetails, 'localCurrencyCode', CONST.CURRENCY.USD), comment: '', participants: [], + receiptPath: '', + receiptSource: '', }); } @@ -1478,6 +1480,50 @@ function setMoneyRequestParticipants(participants) { Onyx.merge(ONYXKEYS.IOU, {participants}); } +/** + * @param {String} receiptPath + * @param {String} receiptSource + */ +function setMoneyRequestReceipt(receiptPath, receiptSource) { + Onyx.merge(ONYXKEYS.IOU, {receiptPath, receiptSource}); +} + +/** + * Navigates to the next IOU page based on where the IOU request was started + * + * @param {Object} iou + * @param {String} iouType + * @param {String} reportID + * @param {Object} report + */ +function navigateToNextPage(iou, iouType, reportID, report) { + const moneyRequestID = `${iouType}${reportID}`; + const shouldReset = iou.id !== moneyRequestID; + // If the money request ID in Onyx does not match the ID from params, we want to start a new request + // with the ID from params. We need to clear the participants in case the new request is initiated from FAB. + if (shouldReset) { + resetMoneyRequestInfo(moneyRequestID); + } + + // If a request is initiated on a report, skip the participants selection step and navigate to the confirmation page. + if (report.reportID) { + // Reinitialize the participants when the money request ID in Onyx does not match the ID from params + if (_.isEmpty(iou.participants) || shouldReset) { + const currentUserAccountID = currentUserPersonalDetails.accountID; + const participants = ReportUtils.isPolicyExpenseChat(report) + ? [{reportID: report.reportID, isPolicyExpenseChat: true, selected: true}] + : _.chain(report.participantAccountIDs) + .filter((accountID) => currentUserAccountID !== accountID) + .map((accountID) => ({accountID, selected: true})) + .value(); + setMoneyRequestParticipants(participants); + } + Navigation.navigate(ROUTES.getMoneyRequestConfirmationRoute(iouType, reportID)); + return; + } + Navigation.navigate(ROUTES.getMoneyRequestParticipantsRoute(iouType)); +} + export { deleteMoneyRequest, splitBill, @@ -1494,4 +1540,6 @@ export { setMoneyRequestCurrency, setMoneyRequestDescription, setMoneyRequestParticipants, + setMoneyRequestReceipt, + navigateToNextPage, }; diff --git a/src/libs/actions/Receipt.js b/src/libs/actions/Receipt.js new file mode 100644 index 000000000000..fbe9c22faaa2 --- /dev/null +++ b/src/libs/actions/Receipt.js @@ -0,0 +1,33 @@ +import Onyx from 'react-native-onyx'; +import ONYXKEYS from '../../ONYXKEYS'; + +/** + * Sets the upload receipt error modal content when an invalid receipt is uploaded + * + * @param {Boolean} isAttachmentInvalid + * @param {String} attachmentInvalidReasonTitle + * @param {String} attachmentInvalidReason + */ +function setUploadReceiptError(isAttachmentInvalid, attachmentInvalidReasonTitle, attachmentInvalidReason) { + Onyx.merge(ONYXKEYS.RECEIPT_MODAL, { + isAttachmentInvalid, + attachmentInvalidReasonTitle, + attachmentInvalidReason, + }); +} + +/** + * Clears the receipt error modal + */ +function clearUploadReceiptError() { + Onyx.merge(ONYXKEYS.RECEIPT_MODAL, { + isAttachmentInvalid: false, + attachmentInvalidReasonTitle: '', + attachmentInvalidReason: '', + }); +} + +export default { + setUploadReceiptError, + clearUploadReceiptError, +}; diff --git a/src/libs/actions/Tab.js b/src/libs/actions/Tab.js new file mode 100644 index 000000000000..0af197361b70 --- /dev/null +++ b/src/libs/actions/Tab.js @@ -0,0 +1,16 @@ +import Onyx from 'react-native-onyx'; +import ONYXKEYS from '../../ONYXKEYS'; + +/** + * Sets the selected tab for a given tab ID + * + * @param {String} id + * @param {String} index + */ +function setSelectedTab(id, index) { + Onyx.merge(`${ONYXKEYS.SELECTED_TAB}_${id}`, index); +} + +export default { + setSelectedTab, +}; diff --git a/src/pages/iou/MoneyRequestDescriptionPage.js b/src/pages/iou/MoneyRequestDescriptionPage.js index 5857c1b0e2d9..07e4b295f85f 100644 --- a/src/pages/iou/MoneyRequestDescriptionPage.js +++ b/src/pages/iou/MoneyRequestDescriptionPage.js @@ -57,7 +57,7 @@ class MoneyRequestDescriptionPage extends Component { IOU.resetMoneyRequestInfo(moneyRequestId); } - if (_.isEmpty(this.props.iou.participants) || this.props.iou.amount === 0 || shouldReset) { + if (_.isEmpty(this.props.iou.participants) || (this.props.iou.amount === 0 && !this.props.iou.receiptPath) || shouldReset) { Navigation.goBack(ROUTES.getMoneyRequestRoute(this.iouType, this.reportID), true); } } @@ -65,7 +65,7 @@ class MoneyRequestDescriptionPage extends Component { // eslint-disable-next-line rulesdir/prefer-early-return componentDidUpdate(prevProps) { // ID in Onyx could change by initiating a new request in a separate browser tab or completing a request - if (_.isEmpty(this.props.iou.participants) || this.props.iou.amount === 0 || prevProps.iou.id !== this.props.iou.id) { + if (_.isEmpty(this.props.iou.participants) || (this.props.iou.amount === 0 && !this.props.iou.receiptPath) || prevProps.iou.id !== this.props.iou.id) { // The ID is cleared on completing a request. In that case, we will do nothing. if (this.props.iou.id) { Navigation.goBack(ROUTES.getMoneyRequestRoute(this.iouType, this.reportID), true); diff --git a/src/pages/iou/MoneyRequestSelectorPage.js b/src/pages/iou/MoneyRequestSelectorPage.js new file mode 100644 index 000000000000..3d59721e404a --- /dev/null +++ b/src/pages/iou/MoneyRequestSelectorPage.js @@ -0,0 +1,129 @@ +import {withOnyx} from 'react-native-onyx'; +import {View} from 'react-native'; +import React from 'react'; +import lodashGet from 'lodash/get'; +import PropTypes from 'prop-types'; +import ONYXKEYS from '../../ONYXKEYS'; +import FullPageNotFoundView from '../../components/BlockingViews/FullPageNotFoundView'; +import ScreenWrapper from '../../components/ScreenWrapper'; +import HeaderWithBackButton from '../../components/HeaderWithBackButton'; +import TabSelector from '../../components/TabSelector/TabSelector'; +import CONST from '../../CONST'; +import useLocalize from '../../hooks/useLocalize'; +import * as IOUUtils from '../../libs/IOUUtils'; +import Navigation from '../../libs/Navigation/Navigation'; +import styles from '../../styles/styles'; +import MoneyRequestAmount from './steps/MoneyRequestAmount'; +import ReceiptSelector from './ReceiptSelector'; +import * as IOU from '../../libs/actions/IOU'; +import DragAndDropProvider from '../../components/DragAndDrop/Provider'; +import usePermissions from '../../hooks/usePermissions'; +import OnyxTabNavigator, {TopTab} from '../../libs/Navigation/OnyxTabNavigator'; +import participantPropTypes from '../../components/participantPropTypes'; + +const propTypes = { + /** React Navigation route */ + route: PropTypes.shape({ + params: PropTypes.shape({ + iouType: PropTypes.string, + reportID: PropTypes.string, + }), + }), + + /** Holds data related to Money Request view state, rather than the underlying Money Request data. */ + iou: PropTypes.shape({ + id: PropTypes.string, + amount: PropTypes.number, + currency: PropTypes.string, + participants: PropTypes.arrayOf(participantPropTypes), + }), + + /** Which tab has been selected */ + selectedTab: PropTypes.string, +}; + +const defaultProps = { + route: { + params: { + iouType: '', + reportID: '', + }, + }, + iou: { + id: '', + amount: 0, + currency: CONST.CURRENCY.USD, + participants: [], + }, + selectedTab: CONST.TAB.MANUAL, +}; + +function MoneyRequestSelectorPage(props) { + const iouType = lodashGet(props.route, 'params.iouType', ''); + const reportID = lodashGet(props.route, 'params.reportID', ''); + const {translate} = useLocalize(); + const {canUseScanReceipts} = usePermissions(); + + const title = { + [CONST.IOU.MONEY_REQUEST_TYPE.REQUEST]: translate('iou.requestMoney'), + [CONST.IOU.MONEY_REQUEST_TYPE.SEND]: translate('iou.sendMoney'), + [CONST.IOU.MONEY_REQUEST_TYPE.SPLIT]: translate('iou.splitBill'), + }; + + const resetMoneyRequestInfo = () => { + const moneyRequestID = `${iouType}${reportID}`; + IOU.resetMoneyRequestInfo(moneyRequestID); + }; + + return ( + + {({safeAreaPaddingBottomStyle}) => ( + + + + + {canUseScanReceipts && iouType === CONST.IOU.MONEY_REQUEST_TYPE.REQUEST ? ( + ( + + )} + > + + + + ) : ( + + )} + + + + )} + + ); +} + +MoneyRequestSelectorPage.propTypes = propTypes; +MoneyRequestSelectorPage.defaultProps = defaultProps; +MoneyRequestSelectorPage.displayName = 'MoneyRequestSelectorPage'; + +export default withOnyx({ + selectedTab: { + key: `${ONYXKEYS.SELECTED_TAB}_${CONST.TAB.RECEIPT_TAB_ID}`, + }, +})(MoneyRequestSelectorPage); diff --git a/src/pages/iou/ReceiptDropUI.js b/src/pages/iou/ReceiptDropUI.js new file mode 100644 index 000000000000..e38e88b4490a --- /dev/null +++ b/src/pages/iou/ReceiptDropUI.js @@ -0,0 +1,44 @@ +import React from 'react'; +import {Text, View} from 'react-native'; +import PropTypes from 'prop-types'; +import CONST from '../../CONST'; +import styles from '../../styles/styles'; +import ReceiptUpload from '../../../assets/images/receipt-upload.svg'; +import useLocalize from '../../hooks/useLocalize'; +import DragAndDropConsumer from '../../components/DragAndDrop/Consumer'; + +const propTypes = { + /** Callback to execute when a file is dropped. */ + onDrop: PropTypes.func.isRequired, + + /** Pixels the receipt image should be shifted down to match the non-drag view UI */ + receiptImageTopPosition: PropTypes.number, +}; + +const defaultProps = { + receiptImageTopPosition: 0, +}; + +function ReceiptDropUI({onDrop, receiptImageTopPosition}) { + const {translate} = useLocalize(); + return ( + + + + + {translate('receipt.dropTitle')} + {translate('receipt.dropMessage')} + + + + ); +} + +ReceiptDropUI.displayName = 'ReceiptDropUI'; +ReceiptDropUI.propTypes = propTypes; +ReceiptDropUI.defaultProps = defaultProps; + +export default ReceiptDropUI; diff --git a/src/pages/iou/ReceiptSelector/index.js b/src/pages/iou/ReceiptSelector/index.js new file mode 100644 index 000000000000..c674878c2c73 --- /dev/null +++ b/src/pages/iou/ReceiptSelector/index.js @@ -0,0 +1,184 @@ +import {View, Text, PixelRatio} from 'react-native'; +import React, {useContext, useState} from 'react'; +import lodashGet from 'lodash/get'; +import PropTypes from 'prop-types'; +import {withOnyx} from 'react-native-onyx'; +import * as IOU from '../../../libs/actions/IOU'; +import reportPropTypes from '../../reportPropTypes'; +import CONST from '../../../CONST'; +import ReceiptUpload from '../../../../assets/images/receipt-upload.svg'; +import PressableWithFeedback from '../../../components/Pressable/PressableWithFeedback'; +import Button from '../../../components/Button'; +import styles from '../../../styles/styles'; +import CopyTextToClipboard from '../../../components/CopyTextToClipboard'; +import ReceiptDropUI from '../ReceiptDropUI'; +import AttachmentPicker from '../../../components/AttachmentPicker'; +import ConfirmModal from '../../../components/ConfirmModal'; +import ONYXKEYS from '../../../ONYXKEYS'; +import Receipt from '../../../libs/actions/Receipt'; +import useWindowDimensions from '../../../hooks/useWindowDimensions'; +import useLocalize from '../../../hooks/useLocalize'; +import {DragAndDropContext} from '../../../components/DragAndDrop/Provider'; +import ReceiptUtils from '../../../libs/ReceiptUtils'; + +const propTypes = { + /** Information shown to the user when a receipt is not valid */ + receiptModal: PropTypes.shape({ + isAttachmentInvalid: PropTypes.bool, + attachmentInvalidReasonTitle: PropTypes.string, + attachmentInvalidReason: PropTypes.string, + }), + + /** The report on which the request is initiated on */ + report: reportPropTypes, + + route: PropTypes.shape({ + params: PropTypes.shape({ + iouType: PropTypes.string, + reportID: PropTypes.string, + }), + }), + + /** Holds data related to Money Request view state, rather than the underlying Money Request data. */ + iou: PropTypes.shape({ + id: PropTypes.string, + amount: PropTypes.number, + currency: PropTypes.string, + participants: PropTypes.arrayOf( + PropTypes.shape({ + accountID: PropTypes.number, + login: PropTypes.string, + isPolicyExpenseChat: PropTypes.bool, + isOwnPolicyExpenseChat: PropTypes.bool, + selected: PropTypes.bool, + }), + ), + }), +}; + +const defaultProps = { + receiptModal: { + isAttachmentInvalid: false, + attachmentInvalidReasonTitle: '', + attachmentInvalidReason: '', + }, + report: {}, + route: { + params: { + iouType: '', + reportID: '', + }, + }, + iou: { + id: '', + amount: 0, + currency: CONST.CURRENCY.USD, + participants: [], + }, +}; + +function ReceiptSelector(props) { + const reportID = lodashGet(props.route, 'params.reportID', ''); + const iouType = lodashGet(props.route, 'params.iouType', ''); + const isAttachmentInvalid = lodashGet(props.receiptModal, 'isAttachmentInvalid', false); + const attachmentInvalidReasonTitle = lodashGet(props.receiptModal, 'attachmentInvalidReasonTitle', ''); + const attachmentInvalidReason = lodashGet(props.receiptModal, 'attachmentInvalidReason', ''); + const [receiptImageTopPosition, setReceiptImageTopPosition] = useState(0); + const {isSmallScreenWidth} = useWindowDimensions(); + const {translate} = useLocalize(); + const {isDraggingOver} = useContext(DragAndDropContext); + + /** + * Sets the Receipt objects and navigates the user to the next page + * @param {Object} file + * @param {Object} iou + * @param {Object} report + */ + const setReceiptAndNavigate = (file, iou, report) => { + if (!ReceiptUtils.validateReceipt(file)) { + return; + } + + const filePath = URL.createObjectURL(file); + IOU.setMoneyRequestReceipt(filePath, file.name); + IOU.navigateToNextPage(iou, iouType, reportID, report); + }; + + return ( + + {!isDraggingOver ? ( + <> + { + setReceiptImageTopPosition(PixelRatio.roundToNearestPixel(nativeEvent.layout.top)); + }} + > + + + {translate('receipt.upload')} + + {isSmallScreenWidth ? translate('receipt.chooseReceipt') : translate('receipt.dragReceiptBeforeEmail')} + + {isSmallScreenWidth ? null : translate('receipt.dragReceiptAfterEmail')} + + + {({openPicker}) => ( + +