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}) => (
+
+
+ )}
+
+ >
+ ) : null}
+ {
+ const file = lodashGet(e, ['dataTransfer', 'files', 0]);
+ setReceiptAndNavigate(file, props.iou, props.report);
+ }}
+ receiptImageTopPosition={receiptImageTopPosition}
+ />
+
+
+ );
+}
+
+ReceiptSelector.defaultProps = defaultProps;
+ReceiptSelector.propTypes = propTypes;
+ReceiptSelector.displayName = 'ReceiptSelector';
+
+export default withOnyx({
+ iou: {key: ONYXKEYS.IOU},
+ receiptModal: {key: ONYXKEYS.RECEIPT_MODAL},
+ report: {
+ key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${lodashGet(route, 'params.reportID', '')}`,
+ },
+})(ReceiptSelector);
diff --git a/src/pages/iou/ReceiptSelector/index.native.js b/src/pages/iou/ReceiptSelector/index.native.js
new file mode 100644
index 000000000000..7eeab6e493bd
--- /dev/null
+++ b/src/pages/iou/ReceiptSelector/index.native.js
@@ -0,0 +1,324 @@
+import {ActivityIndicator, Alert, AppState, Linking, Text, View} from 'react-native';
+import React, {useCallback, useEffect, useRef, useState} from 'react';
+import {Camera, useCameraDevices} from 'react-native-vision-camera';
+import lodashGet from 'lodash/get';
+import PropTypes from 'prop-types';
+import {launchImageLibrary} from 'react-native-image-picker';
+import {withOnyx} from 'react-native-onyx';
+import {useIsFocused} from '@react-navigation/native';
+import PressableWithFeedback from '../../../components/Pressable/PressableWithFeedback';
+import Icon from '../../../components/Icon';
+import * as Expensicons from '../../../components/Icon/Expensicons';
+import styles from '../../../styles/styles';
+import Shutter from '../../../../assets/images/shutter.svg';
+import Hand from '../../../../assets/images/hand.svg';
+import * as IOU from '../../../libs/actions/IOU';
+import themeColors from '../../../styles/themes/default';
+import reportPropTypes from '../../reportPropTypes';
+import CONST from '../../../CONST';
+import Button from '../../../components/Button';
+import useLocalize from '../../../hooks/useLocalize';
+import ONYXKEYS from '../../../ONYXKEYS';
+import Log from '../../../libs/Log';
+import participantPropTypes from '../../../components/participantPropTypes';
+
+const propTypes = {
+ /** Route params */
+ route: PropTypes.shape({
+ params: PropTypes.shape({
+ iouType: PropTypes.string,
+ reportID: PropTypes.string,
+ }),
+ }),
+
+ /** The report on which the request is initiated on */
+ report: reportPropTypes,
+
+ /** 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),
+ }),
+};
+
+const defaultProps = {
+ route: {
+ params: {
+ iouType: '',
+ reportID: '',
+ },
+ },
+ report: {},
+ iou: {
+ id: '',
+ amount: 0,
+ currency: CONST.CURRENCY.USD,
+ participants: [],
+ },
+};
+
+/**
+ * See https://github.com/react-native-image-picker/react-native-image-picker/#options
+ * for ImagePicker configuration options
+ */
+const imagePickerOptions = {
+ includeBase64: false,
+ saveToPhotos: false,
+ selectionLimit: 1,
+ includeExtra: false,
+};
+
+/**
+ * Return imagePickerOptions based on the type
+ * @param {String} type
+ * @returns {Object}
+ */
+function getImagePickerOptions(type) {
+ // mediaType property is one of the ImagePicker configuration to restrict types'
+ const mediaType = type === CONST.ATTACHMENT_PICKER_TYPE.IMAGE ? 'photo' : 'mixed';
+ return {
+ mediaType,
+ ...imagePickerOptions,
+ };
+}
+
+function ReceiptSelector(props) {
+ const devices = useCameraDevices();
+ const device = devices.back;
+
+ const camera = useRef(null);
+ const [flash, setFlash] = useState(false);
+ const [permissions, setPermissions] = useState('authorized');
+ const appState = useRef(AppState.currentState);
+
+ const iouType = lodashGet(props.route, 'params.iouType', '');
+ const reportID = lodashGet(props.route, 'params.reportID', '');
+
+ const {translate} = useLocalize();
+ // Keep track of whether the camera is visible, when we navigate elsewhere, turn off the camera
+ const isFocused = useIsFocused();
+
+ // We want to listen to if the app has come back from background and refresh the permissions status to show camera when permissions were granted
+ useEffect(() => {
+ const subscription = AppState.addEventListener('change', (nextAppState) => {
+ if (appState.current.match(/inactive|background/) && nextAppState === 'active') {
+ Camera.getCameraPermissionStatus().then((permissionStatus) => {
+ setPermissions(permissionStatus);
+ });
+ }
+
+ appState.current = nextAppState;
+ });
+
+ return () => {
+ subscription.remove();
+ };
+ }, []);
+
+ /**
+ * Inform the users when they need to grant camera access and guide them to settings
+ */
+ const showPermissionsAlert = () => {
+ Alert.alert(
+ translate('attachmentPicker.cameraPermissionRequired'),
+ translate('attachmentPicker.expensifyDoesntHaveAccessToCamera'),
+ [
+ {
+ text: translate('common.cancel'),
+ style: 'cancel',
+ },
+ {
+ text: translate('common.settings'),
+ onPress: () => Linking.openSettings(),
+ },
+ ],
+ {cancelable: false},
+ );
+ };
+
+ /**
+ * A generic handling when we don't know the exact reason for an error
+ *
+ */
+ const showGeneralAlert = () => {
+ Alert.alert(translate('attachmentPicker.attachmentError'), translate('attachmentPicker.errorWhileSelectingAttachment'));
+ };
+
+ const askForPermissions = () => {
+ if (permissions === 'not-determined') {
+ Camera.requestCameraPermission().then((permissionStatus) => {
+ setPermissions(permissionStatus);
+ });
+ } else {
+ Linking.openSettings();
+ }
+ };
+
+ /**
+ * Common image picker handling
+ *
+ * @param {function} imagePickerFunc - RNImagePicker.launchCamera or RNImagePicker.launchImageLibrary
+ * @returns {Promise}
+ */
+ const showImagePicker = (imagePickerFunc) =>
+ new Promise((resolve, reject) => {
+ imagePickerFunc(getImagePickerOptions(CONST.ATTACHMENT_PICKER_TYPE.IMAGE), (response) => {
+ if (response.didCancel) {
+ // When the user cancelled resolve with no attachment
+ return resolve();
+ }
+ if (response.errorCode) {
+ switch (response.errorCode) {
+ case 'permission':
+ showPermissionsAlert();
+ return resolve();
+ default:
+ showGeneralAlert();
+ break;
+ }
+
+ return reject(new Error(`Error during attachment selection: ${response.errorMessage}`));
+ }
+
+ return resolve(response.assets);
+ });
+ });
+
+ const takePhoto = useCallback(() => {
+ const showCameraAlert = () => {
+ Alert.alert(translate('receipt.cameraErrorTitle'), translate('receipt.cameraErrorMessage'));
+ };
+
+ if (!camera.current) {
+ showCameraAlert();
+ return;
+ }
+
+ camera.current
+ .takePhoto({
+ qualityPrioritization: 'speed',
+ flash: flash ? 'on' : 'off',
+ })
+ .then((photo) => {
+ IOU.setMoneyRequestReceipt(`file://${photo.path}`, photo.path);
+ IOU.navigateToNextPage(props.iou, iouType, reportID, props.report);
+ })
+ .catch(() => {
+ showCameraAlert();
+ });
+ }, [flash, iouType, props.iou, props.report, reportID, translate]);
+
+ Camera.getCameraPermissionStatus().then((permissionStatus) => {
+ setPermissions(permissionStatus);
+ });
+
+ return (
+
+ {permissions !== CONST.RECEIPT.PERMISSION_AUTHORIZED && (
+
+
+ {translate('receipt.takePhoto')}
+ {translate('receipt.cameraAccess')}
+
+
+
+
+ )}
+ {permissions === CONST.RECEIPT.PERMISSION_AUTHORIZED && device == null && (
+
+
+
+ )}
+ {permissions === CONST.RECEIPT.PERMISSION_AUTHORIZED && device != null && (
+
+ )}
+
+ {
+ showImagePicker(launchImageLibrary)
+ .then((receiptImage) => {
+ IOU.setMoneyRequestReceipt(receiptImage[0].uri, receiptImage[0].fileName);
+ IOU.navigateToNextPage(props.iou, iouType, reportID, props.report);
+ })
+ .catch(() => {
+ Log.info('User did not select an image from gallery');
+ });
+ }}
+ >
+
+
+
+
+
+ setFlash((prevFlash) => !prevFlash)}
+ >
+
+
+
+
+ );
+}
+
+ReceiptSelector.defaultProps = defaultProps;
+ReceiptSelector.propTypes = propTypes;
+ReceiptSelector.displayName = 'ReceiptSelector';
+
+export default withOnyx({
+ iou: {
+ key: ONYXKEYS.IOU,
+ },
+ report: {
+ key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${lodashGet(route, 'params.reportID', '')}`,
+ },
+})(ReceiptSelector);
diff --git a/src/pages/iou/steps/MoneyRequestAmountPage.js b/src/pages/iou/steps/MoneyRequestAmount.js
similarity index 97%
rename from src/pages/iou/steps/MoneyRequestAmountPage.js
rename to src/pages/iou/steps/MoneyRequestAmount.js
index fc27a7779041..c455cbc84c88 100755
--- a/src/pages/iou/steps/MoneyRequestAmountPage.js
+++ b/src/pages/iou/steps/MoneyRequestAmount.js
@@ -12,28 +12,21 @@ import Navigation from '../../../libs/Navigation/Navigation';
import ROUTES from '../../../ROUTES';
import compose from '../../../libs/compose';
import * as ReportUtils from '../../../libs/ReportUtils';
-import * as IOUUtils from '../../../libs/IOUUtils';
import * as CurrencyUtils from '../../../libs/CurrencyUtils';
import Button from '../../../components/Button';
import CONST from '../../../CONST';
import * as DeviceCapabilities from '../../../libs/DeviceCapabilities';
import TextInputWithCurrencySymbol from '../../../components/TextInputWithCurrencySymbol';
-import ScreenWrapper from '../../../components/ScreenWrapper';
-import FullPageNotFoundView from '../../../components/BlockingViews/FullPageNotFoundView';
-import HeaderWithBackButton from '../../../components/HeaderWithBackButton';
import reportPropTypes from '../../reportPropTypes';
import * as IOU from '../../../libs/actions/IOU';
import useLocalize from '../../../hooks/useLocalize';
import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsDefaultProps, withCurrentUserPersonalDetailsPropTypes} from '../../../components/withCurrentUserPersonalDetails';
+import FullPageNotFoundView from '../../../components/BlockingViews/FullPageNotFoundView';
+import ScreenWrapper from '../../../components/ScreenWrapper';
+import HeaderWithBackButton from '../../../components/HeaderWithBackButton';
+import * as IOUUtils from '../../../libs/IOUUtils';
const propTypes = {
- route: PropTypes.shape({
- params: PropTypes.shape({
- iouType: PropTypes.string,
- reportID: PropTypes.string,
- }),
- }),
-
/** The report on which the request is initiated on */
report: reportPropTypes,
@@ -53,16 +46,17 @@ const propTypes = {
),
}),
+ route: PropTypes.shape({
+ params: PropTypes.shape({
+ iouType: PropTypes.string,
+ reportID: PropTypes.string,
+ }),
+ }),
+
...withCurrentUserPersonalDetailsPropTypes,
};
const defaultProps = {
- route: {
- params: {
- iouType: '',
- reportID: '',
- },
- },
report: {},
iou: {
id: '',
@@ -70,6 +64,12 @@ const defaultProps = {
currency: CONST.CURRENCY.USD,
participants: [],
},
+ route: {
+ params: {
+ iouType: '',
+ reportID: '',
+ },
+ },
...withCurrentUserPersonalDetailsDefaultProps,
};
@@ -167,7 +167,7 @@ const replaceAllDigits = (text, convertFn) =>
.join('')
.value();
-function MoneyRequestAmountPage(props) {
+function MoneyRequestAmount(props) {
const {translate, toLocaleDigit, fromLocaleDigit, numberFormat} = useLocalize();
const selectedAmountAsString = props.iou.amount ? CurrencyUtils.convertToWholeUnit(props.iou.currency, props.iou.amount).toString() : '';
@@ -427,10 +427,12 @@ function MoneyRequestAmountPage(props) {
>
{({safeAreaPaddingBottomStyle}) => (
-
+ {isEditing.current && (
+
+ )}
onMouseDown(event, [amountViewID])}
@@ -483,9 +485,9 @@ function MoneyRequestAmountPage(props) {
);
}
-MoneyRequestAmountPage.propTypes = propTypes;
-MoneyRequestAmountPage.defaultProps = defaultProps;
-MoneyRequestAmountPage.displayName = 'MoneyRequestAmountPage';
+MoneyRequestAmount.propTypes = propTypes;
+MoneyRequestAmount.defaultProps = defaultProps;
+MoneyRequestAmount.displayName = 'MoneyRequestAmount';
export default compose(
withCurrentUserPersonalDetails,
@@ -495,4 +497,4 @@ export default compose(
key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${lodashGet(route, 'params.reportID', '')}`,
},
}),
-)(MoneyRequestAmountPage);
+)(MoneyRequestAmount);
diff --git a/src/pages/iou/steps/MoneyRequestConfirmPage.js b/src/pages/iou/steps/MoneyRequestConfirmPage.js
index 4816a395780a..3703a342d00e 100644
--- a/src/pages/iou/steps/MoneyRequestConfirmPage.js
+++ b/src/pages/iou/steps/MoneyRequestConfirmPage.js
@@ -28,6 +28,7 @@ const propTypes = {
iou: PropTypes.shape({
id: PropTypes.string,
amount: PropTypes.number,
+ receiptPath: PropTypes.string,
currency: PropTypes.string,
comment: PropTypes.string,
participants: PropTypes.arrayOf(
@@ -89,14 +90,14 @@ function MoneyRequestConfirmPage(props) {
IOU.resetMoneyRequestInfo(moneyRequestId);
}
- if (_.isEmpty(props.iou.participants) || props.iou.amount === 0 || shouldReset) {
+ if (_.isEmpty(props.iou.participants) || (props.iou.amount === 0 && !props.iou.receiptPath) || shouldReset) {
Navigation.goBack(ROUTES.getMoneyRequestRoute(iouType.current, reportID.current), true);
}
return () => {
prevMoneyRequestId.current = props.iou.id;
};
- }, [props.iou.participants, props.iou.amount, props.iou.id]);
+ }, [props.iou.participants, props.iou.amount, props.iou.id, props.iou.receiptPath]);
const navigateBack = () => {
let fallback;
@@ -206,6 +207,8 @@ function MoneyRequestConfirmPage(props) {
});
IOU.setMoneyRequestParticipants(newParticipants);
}}
+ receiptPath={props.iou.receiptPath}
+ receiptSource={props.iou.receiptSource}
iouType={iouType.current}
reportID={reportID.current}
// The participants can only be modified when the action is initiated from directly within a group chat and not the floating-action-button.
diff --git a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js
index ce26079761eb..d6d069dc0329 100644
--- a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js
+++ b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js
@@ -74,14 +74,14 @@ function MoneyRequestParticipantsPage(props) {
IOU.resetMoneyRequestInfo(moneyRequestId);
}
- if (props.iou.amount === 0 || shouldReset) {
+ if ((props.iou.amount === 0 && !props.iou.receiptPath) || shouldReset) {
navigateBack(true);
}
return () => {
prevMoneyRequestId.current = props.iou.id;
};
- }, [props.iou.amount, props.iou.id]);
+ }, [props.iou.amount, props.iou.id, props.iou.receiptPath]);
return (
{props.translate('contacts.helpTextBeforeEmail')}
{props.translate('contacts.helpTextAfterEmail')}
diff --git a/src/pages/workspace/reimburse/WorkspaceReimburseView.js b/src/pages/workspace/reimburse/WorkspaceReimburseView.js
index ef5896cb0afd..5e7f367a3123 100644
--- a/src/pages/workspace/reimburse/WorkspaceReimburseView.js
+++ b/src/pages/workspace/reimburse/WorkspaceReimburseView.js
@@ -145,7 +145,7 @@ function WorkspaceReimburseView(props) {
{translate('workspace.reimburse.captureNoVBACopyBeforeEmail')}
{translate('workspace.reimburse.captureNoVBACopyAfterEmail')}
diff --git a/src/styles/styles.js b/src/styles/styles.js
index b490baef72f2..b0d3ef380de0 100644
--- a/src/styles/styles.js
+++ b/src/styles/styles.js
@@ -26,6 +26,7 @@ import * as Browser from '../libs/Browser';
import cursor from './utilities/cursor';
import userSelect from './utilities/userSelect';
import textUnderline from './utilities/textUnderline';
+import Colors from './colors';
// touchCallout is an iOS safari only property that controls the display of the callout information when you touch and hold a target
const touchCalloutNone = Browser.isMobileSafari() ? {WebkitTouchCallout: 'none'} : {};
@@ -772,6 +773,39 @@ const styles = {
borderColor: themeColors.danger,
},
+ uploadReceiptView: (isSmallScreenWidth) => ({
+ borderRadius: variables.componentBorderRadiusLarge,
+ borderWidth: isSmallScreenWidth ? 0 : 2,
+ borderColor: themeColors.borderFocus,
+ borderStyle: 'dotted',
+ marginBottom: 20,
+ marginLeft: 20,
+ marginRight: 20,
+ justifyContent: 'center',
+ alignItems: 'center',
+ padding: 40,
+ gap: 4,
+ flex: 1,
+ }),
+
+ cameraView: {
+ flex: 1,
+ overflow: 'hidden',
+ padding: 10,
+ borderRadius: 28,
+ borderStyle: 'solid',
+ borderWidth: 8,
+ backgroundColor: Colors.greenHighlightBackground,
+ borderColor: Colors.greenAppBackground,
+ },
+
+ permissionView: {
+ paddingVertical: 108,
+ paddingHorizontal: 61,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+
headerAnonymousFooter: {
color: themeColors.heading,
fontFamily: fontFamily.EXP_NEW_KANSAS_MEDIUM,
@@ -1075,6 +1109,20 @@ const styles = {
color: themeColors.textSupporting,
},
+ textReceiptUpload: {
+ ...headlineFont,
+ fontSize: variables.fontSizeXLarge,
+ color: themeColors.textLight,
+ textAlign: 'center',
+ },
+
+ subTextReceiptUpload: {
+ fontFamily: fontFamily.EXP_NEUE,
+ lineHeight: variables.lineHeightLarge,
+ textAlign: 'center',
+ color: themeColors.textLight,
+ },
+
furtherDetailsText: {
fontFamily: fontFamily.EXP_NEUE,
fontSize: variables.fontSizeSmall,
@@ -3174,6 +3222,16 @@ const styles = {
zIndex: 2,
},
+ receiptDropOverlay: {
+ backgroundColor: themeColors.receiptDropUIBG,
+ zIndex: 2,
+ },
+
+ receiptImageWrapper: (receiptImageTopPosition) => ({
+ position: 'absolute',
+ top: receiptImageTopPosition,
+ }),
+
cardSection: {
backgroundColor: themeColors.cardBG,
borderRadius: variables.componentBorderRadiusCard,
@@ -3533,6 +3591,29 @@ const styles = {
textAlign: 'center',
},
+ tabSelectorButton: (isSelected) => ({
+ height: 40,
+ padding: 12,
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderRadius: variables.buttonBorderRadius,
+ backgroundColor: isSelected ? themeColors.midtone : themeColors.appBG,
+ }),
+
+ tabSelector: {
+ flexDirection: 'row',
+ paddingHorizontal: 20,
+ paddingBottom: 12,
+ },
+
+ tabText: (isSelected) => ({
+ marginHorizontal: 8,
+ fontFamily: isSelected ? fontFamily.EXP_NEUE_BOLD : fontFamily.EXP_NEUE,
+ fontWeight: isSelected ? fontWeightBold : 400,
+ color: isSelected ? themeColors.textLight : themeColors.textSupporting,
+ }),
+
/**
* @param {String} backgroundColor
* @param {Number} height
@@ -3551,6 +3632,12 @@ const styles = {
willChangeTransform: {
willChange: 'transform',
},
+
+ moneyRequestImage: {
+ height: 200,
+ borderRadius: 16,
+ margin: 20,
+ },
};
export default styles;
diff --git a/src/styles/themes/default.js b/src/styles/themes/default.js
index 0cd32e170a7e..bc794f93dfab 100644
--- a/src/styles/themes/default.js
+++ b/src/styles/themes/default.js
@@ -65,6 +65,7 @@ const darkTheme = {
heroCard: colors.blue,
uploadPreviewActivityIndicator: colors.greenHighlightBackground,
dropUIBG: 'rgba(6,27,9,0.92)',
+ receiptDropUIBG: 'rgba(3, 212, 124, 0.84)',
checkBox: colors.green,
pickerOptionsTextColor: colors.white,
imageCropBackgroundColor: colors.greenIcons,
diff --git a/src/styles/utilities/spacing.js b/src/styles/utilities/spacing.js
index f70c8fbf419f..71c5afb6a65e 100644
--- a/src/styles/utilities/spacing.js
+++ b/src/styles/utilities/spacing.js
@@ -262,6 +262,11 @@ export default {
p5: {
padding: 20,
},
+
+ p9: {
+ padding: 36,
+ },
+
p10: {
padding: 40,
},