diff --git a/assets/images/sparkles.svg b/assets/images/sparkles.svg new file mode 100644 index 0000000000000..d878a4e49f1b1 --- /dev/null +++ b/assets/images/sparkles.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts index 1a885a21901c2..aebba30cc1590 100644 --- a/src/components/Icon/Expensicons.ts +++ b/src/components/Icon/Expensicons.ts @@ -192,6 +192,7 @@ import Instagram from '@assets/images/social-instagram.svg'; import Linkedin from '@assets/images/social-linkedin.svg'; import Podcast from '@assets/images/social-podcast.svg'; import Twitter from '@assets/images/social-twitter.svg'; +import Sparkles from '@assets/images/sparkles.svg'; import SpreadsheetComputer from '@assets/images/spreadsheet-computer.svg'; import Star from '@assets/images/Star.svg'; import Stopwatch from '@assets/images/stopwatch.svg'; @@ -379,6 +380,7 @@ export { Send, Shield, SmartScan, + Sparkles, Stopwatch, Suitcase, Sync, diff --git a/src/components/Image/index.tsx b/src/components/Image/index.tsx index 7964b53496720..8d658c97af362 100644 --- a/src/components/Image/index.tsx +++ b/src/components/Image/index.tsx @@ -18,6 +18,7 @@ function Image({ loadingIconSize, loadingIndicatorStyles, imageWidthToCalculateHeight, + shouldUseFullHeight, ...forwardedProps }: ImageProps) { const [aspectRatio, setAspectRatio] = useState(null); @@ -31,6 +32,12 @@ function Image({ return {}; } + if (shouldUseFullHeight) { + return { + height: '100%', + }; + } + if (!!imageWidthToCalculateHeight && typeof aspectRatio === 'number') { return { width: '100%', @@ -39,11 +46,11 @@ function Image({ } return {aspectRatio, height: 'auto'}; - }, [shouldSetAspectRatioInStyle, aspectRatio, imageWidthToCalculateHeight]); + }, [shouldSetAspectRatioInStyle, aspectRatio, imageWidthToCalculateHeight, shouldUseFullHeight]); const updateAspectRatio = useCallback( (width: number, height: number) => { - if (!isObjectPositionTop) { + if (!isObjectPositionTop || shouldUseFullHeight) { return; } @@ -54,7 +61,7 @@ function Image({ setAspectRatio(height ? width / height : 'auto'); }, - [isObjectPositionTop, shouldCalculateAspectRatioForWideImage], + [isObjectPositionTop, shouldCalculateAspectRatioForWideImage, shouldUseFullHeight], ); const handleLoad = useCallback( @@ -150,7 +157,7 @@ function Image({ /** * If the image fails to load and the object position is top, we should hide the image by setting the opacity to 0. */ - const shouldOpacityBeZero = isObjectPositionTop && !aspectRatio; + const shouldOpacityBeZero = isObjectPositionTop && !aspectRatio && !shouldUseFullHeight; if (source === undefined && !!forwardedProps?.waitForSession) { return undefined; diff --git a/src/components/Image/types.ts b/src/components/Image/types.ts index 7a57250b98484..45178ece3629b 100644 --- a/src/components/Image/types.ts +++ b/src/components/Image/types.ts @@ -69,6 +69,9 @@ type ImageOwnProps = BaseImageProps & { /** If you want to calculate the image height dynamically instead of using aspectRatio, pass the width in this property */ imageWidthToCalculateHeight?: number; + + /** Whether the image should use the full height of the container */ + shouldUseFullHeight?: boolean; }; type ImageProps = ImageOwnProps; diff --git a/src/components/ImageWithSizeCalculation.tsx b/src/components/ImageWithSizeCalculation.tsx index 59af932c72b7c..69c4dae99bd4b 100644 --- a/src/components/ImageWithSizeCalculation.tsx +++ b/src/components/ImageWithSizeCalculation.tsx @@ -44,6 +44,9 @@ type ImageWithSizeCalculationProps = { /** The style of the loading indicator */ loadingIndicatorStyles?: StyleProp; + /** Whether the image should use the full height of the container */ + shouldUseFullHeight?: boolean; + /** Callback to be called when the image loads */ onLoad?: (event: {nativeEvent: {width: number; height: number}}) => void; }; @@ -64,6 +67,7 @@ function ImageWithSizeCalculation({ objectPosition = CONST.IMAGE_OBJECT_POSITION.INITIAL, loadingIconSize, loadingIndicatorStyles, + shouldUseFullHeight, onLoad, }: ImageWithSizeCalculationProps) { const styles = useThemeStyles(); @@ -94,6 +98,7 @@ function ImageWithSizeCalculation({ objectPosition={objectPosition} loadingIconSize={loadingIconSize} loadingIndicatorStyles={loadingIndicatorStyles} + shouldUseFullHeight={shouldUseFullHeight} /> ); } diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx index b92385c5aa6f0..9346293741e98 100644 --- a/src/components/MenuItem.tsx +++ b/src/components/MenuItem.tsx @@ -196,6 +196,9 @@ type MenuItemBaseProps = { /** Label to be displayed on the right */ rightLabel?: string; + /** Icon to be displayed next to the right label */ + rightLabelIcon?: IconAsset; + /** Text to display for the item */ title?: string; @@ -449,6 +452,7 @@ function MenuItem({ titleContainerStyle, subtitle, shouldShowBasicTitle, + rightLabelIcon, label, shouldTruncateTitle = false, characterLimit = 200, @@ -934,7 +938,15 @@ function MenuItem({ )} {!title && !!rightLabel && !errorText && ( - + + {!!rightLabelIcon && ( + + )} {rightLabel} )} diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 3b76306348980..e9bd45d7f20e7 100755 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -12,6 +12,7 @@ import usePermissions from '@hooks/usePermissions'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import usePreferredPolicy from '@hooks/usePreferredPolicy'; import usePrevious from '@hooks/usePrevious'; +import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import blurActiveElement from '@libs/Accessibility/blurActiveElement'; import { @@ -286,6 +287,7 @@ function MoneyRequestConfirmationList({ const defaultMileageRate = defaultMileageRateDraft ?? defaultMileageRateReal; const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); const {translate, toLocaleDigit} = useLocalize(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const {isRestrictedToPreferredPolicy} = usePreferredPolicy(); @@ -383,6 +385,7 @@ function MoneyRequestConfirmationList({ const [didConfirm, setDidConfirm] = useState(isConfirmed); const [didConfirmSplit, setDidConfirmSplit] = useState(false); + const [showMoreFields, setShowMoreFields] = useState(false); // Clear the form error if it's set to one among the list passed as an argument const clearFormErrors = useCallback( @@ -1093,6 +1096,9 @@ function MoneyRequestConfirmationList({ reportID, ]); + const isScan = isScanRequestUtil(transaction); + const shouldRestrictHeight = useMemo(() => !showMoreFields && isScan, [isScan, showMoreFields]); + const listFooterContent = ( ); @@ -1164,6 +1172,8 @@ function MoneyRequestConfirmationList({ containerStyle={[styles.flexBasisAuto]} removeClippedSubviews={false} disableKeyboardShortcuts + contentContainerStyle={shouldRestrictHeight ? [StyleUtils.getReceiptContainerStyles()] : undefined} + ListFooterComponentStyle={shouldRestrictHeight ? [styles.flex1] : undefined} /> ); diff --git a/src/components/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx index 86d8c030a51e6..38911428afd1b 100644 --- a/src/components/MoneyRequestConfirmationListFooter.tsx +++ b/src/components/MoneyRequestConfirmationListFooter.tsx @@ -2,15 +2,17 @@ import {emailSelector} from '@selectors/Session'; import {format} from 'date-fns'; import {Str} from 'expensify-common'; import {deepEqual} from 'fast-equals'; -import React, {memo, useMemo} from 'react'; +import React, {memo, useCallback, useMemo} from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; +import Animated, {Easing, FadeInDown, FadeOutUp, LinearTransition} from 'react-native-reanimated'; import type {ValueOf} from 'type-fest'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import usePrevious from '@hooks/usePrevious'; +import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {getDecodedCategoryName} from '@libs/CategoryUtils'; import {convertToDisplayString} from '@libs/CurrencyUtils'; @@ -31,10 +33,13 @@ import { isCreatedMissing, isFetchingWaypointsFromServer, isManagedCardTransaction, + isScanRequest, shouldShowAttendees as shouldShowAttendeesTransactionUtils, + willFieldBeAutomaticallyFilled, } from '@libs/TransactionUtils'; import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot'; import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; +import variables from '@styles/variables'; import CONST from '@src/CONST'; import type {IOUAction, IOUType} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -44,8 +49,10 @@ import type {Attendee, Participant} from '@src/types/onyx/IOU'; import type {Unit} from '@src/types/onyx/Policy'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import Badge from './Badge'; +import Button from './Button'; import ConfirmedRoute from './ConfirmedRoute'; import MentionReportContext from './HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/MentionReportContext'; +import Icon from './Icon'; import * as Expensicons from './Icon/Expensicons'; import MenuItem from './MenuItem'; import MenuItemWithTopDescription from './MenuItemWithTopDescription'; @@ -54,6 +61,7 @@ import PressableWithoutFocus from './Pressable/PressableWithoutFocus'; import ReceiptEmptyState from './ReceiptEmptyState'; import ReceiptImage from './ReceiptImage'; import {ShowContextMenuContext} from './ShowContextMenuContext'; +import Text from './Text'; type MoneyRequestConfirmationListFooterProps = { /** The action to perform */ @@ -206,6 +214,12 @@ type MoneyRequestConfirmationListFooterProps = { /** Flag indicating if the IOU is reimbursable */ iouIsReimbursable: boolean; + /** Whether to show more fields */ + showMoreFields: boolean; + + /** Function to set the show more fields */ + setShowMoreFields: (showMoreFields: boolean) => void; + /** Flag indicating if the description is required */ isDescriptionRequired: boolean; }; @@ -261,11 +275,14 @@ function MoneyRequestConfirmationListFooter({ iouIsReimbursable, onToggleReimbursable, isReceiptEditable = false, + showMoreFields, + setShowMoreFields, isDescriptionRequired = false, }: MoneyRequestConfirmationListFooterProps) { const styles = useThemeStyles(); const {translate, toLocaleDigit, localeCompare} = useLocalize(); const {isOffline} = useNetwork(); + const theme = useTheme(); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: true}); const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {canBeMissing: true}); @@ -276,6 +293,7 @@ function MoneyRequestConfirmationListFooter({ const {policyForMovingExpensesID, shouldSelectPolicy} = usePolicyForMovingExpenses(); const [currentUserLogin] = useOnyx(ONYXKEYS.SESSION, {selector: emailSelector, canBeMissing: true}); + const isScan = isScanRequest(transaction); const isUnreported = transaction?.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; const isCreatingTrackExpense = action === CONST.IOU.ACTION.CREATE && iouType === CONST.IOU.TYPE.TRACK; @@ -413,6 +431,26 @@ function MoneyRequestConfirmationListFooter({ const mentionReportContextValue = useMemo(() => ({currentReportID: reportID, exactlyMatch: true}), [reportID]); + const getRightLabelIcon = useCallback( + (fieldType: 'amount' | 'merchant' | 'date' | 'category') => { + return willFieldBeAutomaticallyFilled(transaction, fieldType) ? Expensicons.Sparkles : undefined; + }, + [transaction], + ); + + const getRightLabel = useCallback( + (fieldType: 'amount' | 'merchant' | 'date' | 'category', isRequiredField = false) => { + if (willFieldBeAutomaticallyFilled(transaction, fieldType)) { + return translate('common.automatic'); + } + if (isRequiredField) { + return translate('common.required'); + } + return ''; + }, + [transaction, translate], + ); + const fields = [ { item: ( @@ -439,6 +477,7 @@ function MoneyRequestConfirmationListFooter({ /> ), shouldShow: shouldShowSmartScanFields && shouldShowAmountField, + isRequired: true, }, { item: ( @@ -472,6 +511,7 @@ function MoneyRequestConfirmationListFooter({ ), shouldShow: true, + isRequired: true, }, { item: ( @@ -499,6 +539,7 @@ function MoneyRequestConfirmationListFooter({ /> ), shouldShow: isDistanceRequest, + isRequired: true, }, { item: ( @@ -536,6 +577,7 @@ function MoneyRequestConfirmationListFooter({ /> ), shouldShow: isDistanceRequest, + isRequired: false, }, { item: ( @@ -557,11 +599,13 @@ function MoneyRequestConfirmationListFooter({ interactive={!isReadOnly} brickRoadIndicator={shouldDisplayMerchantError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} errorText={shouldDisplayMerchantError ? translate('common.error.fieldRequired') : ''} - rightLabel={isMerchantRequired && !shouldDisplayMerchantError ? translate('common.required') : ''} + rightLabel={getRightLabel('merchant', !!isMerchantRequired && !shouldDisplayMerchantError)} + rightLabelIcon={getRightLabelIcon('merchant')} numberOfLinesTitle={2} /> ), shouldShow: shouldShowMerchant, + isRequired: false, }, { item: ( @@ -601,10 +645,12 @@ function MoneyRequestConfirmationListFooter({ titleStyle={styles.flex1} disabled={didConfirm} interactive={!isReadOnly} - rightLabel={isCategoryRequired ? translate('common.required') : ''} + rightLabel={getRightLabel('category', isCategoryRequired)} + rightLabelIcon={getRightLabelIcon('category')} /> ), shouldShow: shouldShowCategories, + isRequired: false, }, { item: ( @@ -630,6 +676,7 @@ function MoneyRequestConfirmationListFooter({ /> ), shouldShow: shouldShowDate, + isRequired: false, }, ...policyTagLists.map(({name}, index) => { const tagVisibilityItem = tagVisibility.at(index); @@ -663,6 +710,7 @@ function MoneyRequestConfirmationListFooter({ /> ), shouldShow, + isRequired: false, }; }), { @@ -686,6 +734,7 @@ function MoneyRequestConfirmationListFooter({ /> ), shouldShow: shouldShowTax, + isRequired: false, }, { item: ( @@ -708,6 +757,7 @@ function MoneyRequestConfirmationListFooter({ /> ), shouldShow: shouldShowTax, + isRequired: false, }, { item: ( @@ -732,6 +782,7 @@ function MoneyRequestConfirmationListFooter({ /> ), shouldShow: shouldShowAttendees, + isRequired: false, }, { item: ( @@ -751,6 +802,7 @@ function MoneyRequestConfirmationListFooter({ ), shouldShow: shouldShowReimbursable, isSupplementary: true, + isRequired: false, }, { item: ( @@ -769,6 +821,7 @@ function MoneyRequestConfirmationListFooter({ ), shouldShow: shouldShowBillable, + isRequired: false, }, { item: ( @@ -790,6 +843,7 @@ function MoneyRequestConfirmationListFooter({ /> ), shouldShow: isPolicyExpenseChat, + isRequired: false, }, ]; @@ -852,9 +906,14 @@ function MoneyRequestConfirmationListFooter({ return badges; }, [firstDay, lastDay, translate, tripDays]); + const shouldRestrictHeight = useMemo(() => !showMoreFields && isScan, [isScan, showMoreFields]); + const receiptThumbnailContent = useMemo( () => ( - + {isLocalFile && Str.isPDF(receiptFilename) ? ( { @@ -910,17 +969,20 @@ function MoneyRequestConfirmationListFooter({ fileExtension={fileExtension} shouldUseThumbnailImage shouldUseInitialObjectPosition={isDistanceRequest} + shouldUseFullHeight /> )} - + ), [ styles.moneyRequestImage, - styles.expenseViewImageSmall, + styles.receiptPreviewAspectRatio, styles.cursorDefault, styles.h100, styles.flex1, + styles.expenseViewImageSmall, + shouldRestrictHeight, isLocalFile, receiptFilename, translate, @@ -934,10 +996,10 @@ function MoneyRequestConfirmationListFooter({ fileExtension, isDistanceRequest, transactionID, + isReceiptEditable, + reportID, action, iouType, - reportID, - isReceiptEditable, ], ); @@ -1016,7 +1078,7 @@ function MoneyRequestConfirmationListFooter({ )} {(!shouldShowMap || isManualDistanceRequest) && ( - + {hasReceiptImageOrThumbnail ? receiptThumbnailContent : showReceiptEmptyState && ( @@ -1028,12 +1090,59 @@ function MoneyRequestConfirmationListFooter({ Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID, Navigation.getActiveRoute())); }} - style={styles.expenseViewImageSmall} + style={[styles.expenseViewImageSmall, !shouldRestrictHeight && styles.receiptPreviewAspectRatio]} /> )} )} - {fields.filter((field) => field.shouldShow).map((field) => field.item)} + + + {isScan && ( + + + {translate('iou.automaticallyEnterExpenseDetails')} + + )} + + {fields.filter((field) => field.shouldShow && (field.isRequired ?? false)).map((field) => field.item)} + + {!shouldRestrictHeight && + fields + .filter((field) => field.shouldShow && !(field.isRequired ?? false)) + .map((field) => ( + + {field.item} + + ))} + + {shouldRestrictHeight && fields.some((field) => field.shouldShow && !(field.isRequired ?? false)) && ( + + +