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/CONST/index.ts b/src/CONST/index.ts index 9f57a60e79b9e..a982afbc75eaf 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -8137,6 +8137,7 @@ const CONST = { }, SELECTION_LIST_WITH_SECTIONS: { BASE_LIST_ITEM: 'SelectionListWithSections-BaseListItem', + SELECT_ALL: 'SelectionListWithSections-SelectAll', }, CONTEXT_MENU: { REPLY_IN_THREAD: 'ContextMenu-ReplyInThread', diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts index 60746b906dfc4..1468f94525e50 100644 --- a/src/components/Icon/Expensicons.ts +++ b/src/components/Icon/Expensicons.ts @@ -138,6 +138,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'; @@ -266,6 +267,7 @@ export { Scan, Send, Shield, + Sparkles, Stopwatch, Sync, Task, diff --git a/src/components/Icon/chunks/expensify-icons.chunk.ts b/src/components/Icon/chunks/expensify-icons.chunk.ts index 8255f17c6f249..c846f1550ba9a 100644 --- a/src/components/Icon/chunks/expensify-icons.chunk.ts +++ b/src/components/Icon/chunks/expensify-icons.chunk.ts @@ -204,6 +204,7 @@ 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 Youtube from '@assets/images/social-youtube.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'; @@ -463,6 +464,7 @@ const Expensicons = { Feed, Table, SpreadsheetComputer, + Sparkles, Bookmark, Star, QBDSquare, diff --git a/src/components/ImageWithLoading.tsx b/src/components/ImageWithLoading.tsx index 513e113fd75f4..3a84db339adf8 100644 --- a/src/components/ImageWithLoading.tsx +++ b/src/components/ImageWithLoading.tsx @@ -87,6 +87,7 @@ function ImageWithLoading({ // eslint-disable-next-line react/jsx-props-no-spreading {...rest} style={[styles.w100, styles.h100, style]} + resizeMode={resizeMode} onLoadStart={() => { if (isLoadedRef.current ?? isLoading) { return; diff --git a/src/components/ImageWithSizeCalculation.tsx b/src/components/ImageWithSizeCalculation.tsx index 0488d3a541815..afde73cf29a51 100644 --- a/src/components/ImageWithSizeCalculation.tsx +++ b/src/components/ImageWithSizeCalculation.tsx @@ -1,5 +1,5 @@ import React, {useMemo} from 'react'; -import type {ImageSourcePropType, StyleProp, ViewStyle} from 'react-native'; +import type {ImageResizeMode, ImageSourcePropType, StyleProp, ViewStyle} from 'react-native'; import useThemeStyles from '@hooks/useThemeStyles'; import Log from '@libs/Log'; import CONST from '@src/CONST'; @@ -46,6 +46,9 @@ type ImageWithSizeCalculationProps = { /** Callback to be called when the image loads */ onLoad?: (event: {nativeEvent: {width: number; height: number}}) => void; + + /** The resize mode of the image */ + resizeMode?: ImageResizeMode; }; /** @@ -65,6 +68,7 @@ function ImageWithSizeCalculation({ loadingIconSize, loadingIndicatorStyles, onLoad, + resizeMode, }: ImageWithSizeCalculationProps) { const styles = useThemeStyles(); @@ -82,7 +86,7 @@ function ImageWithSizeCalculation({ source={source} aria-label={altText} isAuthTokenRequired={isAuthTokenRequired} - resizeMode={RESIZE_MODES.cover} + resizeMode={resizeMode ?? RESIZE_MODES.cover} onError={onError} onLoad={(event: OnLoadNativeEvent) => { onMeasure({ diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx index e8f2f699be65a..1af9e959b822f 100644 --- a/src/components/MenuItem.tsx +++ b/src/components/MenuItem.tsx @@ -211,6 +211,9 @@ type MenuItemBaseProps = ForwardedFSClassProps & /** 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; @@ -498,6 +501,7 @@ function MenuItem({ titleContainerStyle, subtitle, shouldShowBasicTitle, + rightLabelIcon, label, shouldTruncateTitle = false, characterLimit = 200, @@ -1018,7 +1022,15 @@ function MenuItem({ )} {!title && !!rightLabel && !errorText && ( - + + {!!rightLabelIcon && ( + + )} {rightLabel} )} diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 053e456ef7072..33df45196033e 100755 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -75,6 +75,7 @@ import type {DropdownOption} from './ButtonWithDropdownMenu/types'; import {DelegateNoAccessContext} from './DelegateNoAccessModalProvider'; import FormHelpMessage from './FormHelpMessage'; import MoneyRequestAmountInput from './MoneyRequestAmountInput'; +import getContentContainerStyle from './MoneyRequestConfirmationList/getContentContainerStyles'; import MoneyRequestConfirmationListFooter from './MoneyRequestConfirmationListFooter'; import {PressableWithFeedback} from './Pressable'; import {useProductTrainingContext} from './ProductTrainingContext'; @@ -417,6 +418,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( @@ -1239,6 +1241,8 @@ function MoneyRequestConfirmationList({ reportID, ]); + const isCompactMode = useMemo(() => !showMoreFields && isScanRequest, [isScanRequest, showMoreFields]); + const listFooterContent = ( ); @@ -1315,6 +1321,8 @@ function MoneyRequestConfirmationList({ containerStyle={[styles.flexBasisAuto]} removeClippedSubviews={false} disableKeyboardShortcuts + contentContainerStyle={getContentContainerStyle(isCompactMode, styles.flex1).contentContainerStyle} + ListFooterComponentStyle={isCompactMode ? [styles.flex1] : undefined} /> ); diff --git a/src/components/MoneyRequestConfirmationList/getContentContainerStyles/index.native.ts b/src/components/MoneyRequestConfirmationList/getContentContainerStyles/index.native.ts new file mode 100644 index 0000000000000..7531fdc97b63a --- /dev/null +++ b/src/components/MoneyRequestConfirmationList/getContentContainerStyles/index.native.ts @@ -0,0 +1,7 @@ +import type GetContentContainerStyle from './types'; + +const getContentContainerStyle: GetContentContainerStyle = () => ({ + contentContainerStyle: undefined, +}); + +export default getContentContainerStyle; diff --git a/src/components/MoneyRequestConfirmationList/getContentContainerStyles/index.ts b/src/components/MoneyRequestConfirmationList/getContentContainerStyles/index.ts new file mode 100644 index 0000000000000..79e704c2f9fbf --- /dev/null +++ b/src/components/MoneyRequestConfirmationList/getContentContainerStyles/index.ts @@ -0,0 +1,7 @@ +import type GetContentContainerStyle from './types'; + +const getContentContainerStyle: GetContentContainerStyle = (isCompactMode, flex1Style) => ({ + contentContainerStyle: isCompactMode ? [flex1Style] : undefined, +}); + +export default getContentContainerStyle; diff --git a/src/components/MoneyRequestConfirmationList/getContentContainerStyles/types.ts b/src/components/MoneyRequestConfirmationList/getContentContainerStyles/types.ts new file mode 100644 index 0000000000000..52a4c05e9378d --- /dev/null +++ b/src/components/MoneyRequestConfirmationList/getContentContainerStyles/types.ts @@ -0,0 +1,10 @@ +import type {StyleProp, ViewStyle} from 'react-native'; + +type GetContentContainerStyle = ( + isCompactMode: boolean, + flex1Style: ViewStyle, +) => { + contentContainerStyle: StyleProp | undefined; +}; + +export default GetContentContainerStyle; diff --git a/src/components/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx index a84ee387b2926..5700da4ca8fa3 100644 --- a/src/components/MoneyRequestConfirmationListFooter.tsx +++ b/src/components/MoneyRequestConfirmationListFooter.tsx @@ -2,8 +2,9 @@ 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, useState} from 'react'; import {View} from 'react-native'; +import type {ImageResizeMode, ViewStyle} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import useCurrencyList from '@hooks/useCurrencyList'; @@ -15,7 +16,9 @@ import useOnyx from '@hooks/useOnyx'; import useOutstandingReports from '@hooks/useOutstandingReports'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import usePrevious from '@hooks/usePrevious'; +import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; import {getDecodedCategoryName} from '@libs/CategoryUtils'; import {convertToDisplayString} from '@libs/CurrencyUtils'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; @@ -36,10 +39,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'; @@ -49,15 +55,19 @@ 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 MenuItem from './MenuItem'; import MenuItemWithTopDescription from './MenuItemWithTopDescription'; +import getImageCompactModeStyle from './MoneyRequestConfirmationListFooter/getImageCompactModeStyle'; import PDFThumbnail from './PDFThumbnail'; 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 */ @@ -227,6 +237,12 @@ type MoneyRequestConfirmationListFooterProps = { /** Flag indicating if the description is required */ isDescriptionRequired: boolean; + + /** Whether to show more fields */ + showMoreFields: boolean; + + /** Function to set the show more fields */ + setShowMoreFields: (showMoreFields: boolean) => void; }; function MoneyRequestConfirmationListFooter({ @@ -286,13 +302,15 @@ function MoneyRequestConfirmationListFooter({ onToggleReimbursable, isReceiptEditable = false, isDescriptionRequired = false, + showMoreFields, + setShowMoreFields, }: MoneyRequestConfirmationListFooterProps) { - const icons = useMemoizedLazyExpensifyIcons(['Stopwatch', 'CalendarSolid']); + const icons = useMemoizedLazyExpensifyIcons(['Stopwatch', 'CalendarSolid', 'Sparkles', 'DownArrow'] as const); const styles = useThemeStyles(); + const theme = useTheme(); const {translate, toLocaleDigit, localeCompare} = useLocalize(); const {getCurrencySymbol} = useCurrencyList(); const {isOffline} = useNetwork(); - const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: true}); const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {canBeMissing: true}); const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, {canBeMissing: true}); @@ -307,6 +325,7 @@ function MoneyRequestConfirmationListFooter({ const isCreatingTrackExpense = action === CONST.IOU.ACTION.CREATE && iouType === CONST.IOU.TYPE.TRACK; const decodedCategoryName = useMemo(() => getDecodedCategoryName(iouCategory), [iouCategory]); + const isScan = isScanRequest(transaction); const isTrackExpense = iouType === CONST.IOU.TYPE.TRACK; const shouldShowTags = useMemo( @@ -418,6 +437,7 @@ function MoneyRequestConfirmationListFooter({ } = receiptPath && receiptFilename ? getThumbnailAndImageURIs(transaction, receiptPath, receiptFilename) : ({} as ThumbnailAndImageURI); const resolvedThumbnail = isLocalFile ? receiptThumbnail : tryResolveUrlFromApiRoot(receiptThumbnail ?? ''); const resolvedReceiptImage = isLocalFile ? receiptImage : tryResolveUrlFromApiRoot(receiptImage ?? ''); + const shouldRequireAuthToken = !!receiptThumbnail && !isLocalFile; const shouldNavigateToUpgradePath = !policyForMovingExpensesID && !shouldSelectPolicy; // Time requests appear as regular expenses after they're created, with editable amount and merchant, not hours and rate @@ -452,6 +472,23 @@ function MoneyRequestConfirmationListFooter({ const mentionReportContextValue = useMemo(() => ({currentReportID: reportID, exactlyMatch: true}), [reportID]); + const getRightLabelIcon = useCallback(() => { + return willFieldBeAutomaticallyFilled(transaction, 'category') ? icons.Sparkles : undefined; + }, [transaction, icons.Sparkles]); + + const getRightLabel = useCallback( + (isRequiredField = false) => { + if (willFieldBeAutomaticallyFilled(transaction, 'category')) { + return translate('common.automatic'); + } + if (isRequiredField) { + return translate('common.required'); + } + return ''; + }, + [transaction, translate], + ); + const fields = [ { item: ( @@ -478,6 +515,7 @@ function MoneyRequestConfirmationListFooter({ /> ), shouldShow: shouldShowSmartScanFields && shouldShowAmountField, + shouldShowAboveShowMore: false, }, { item: ( @@ -511,6 +549,7 @@ function MoneyRequestConfirmationListFooter({ ), shouldShow: true, + shouldShowAboveShowMore: true, }, { item: ( @@ -543,6 +582,7 @@ function MoneyRequestConfirmationListFooter({ /> ), shouldShow: isDistanceRequest, + shouldShowAboveShowMore: true, }, { item: ( @@ -580,6 +620,7 @@ function MoneyRequestConfirmationListFooter({ /> ), shouldShow: isDistanceRequest, + shouldShowAboveShowMore: false, }, { item: ( @@ -606,6 +647,7 @@ function MoneyRequestConfirmationListFooter({ /> ), shouldShow: shouldShowMerchant, + shouldShowAboveShowMore: false, }, { item: ( @@ -687,12 +729,14 @@ function MoneyRequestConfirmationListFooter({ titleStyle={styles.flex1} disabled={didConfirm} interactive={!isReadOnly} - rightLabel={isCategoryRequired ? translate('common.required') : ''} + rightLabel={getRightLabel(isCategoryRequired)} + rightLabelIcon={getRightLabelIcon()} brickRoadIndicator={shouldDisplayCategoryError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} errorText={shouldDisplayCategoryError ? translate(formError) : ''} /> ), shouldShow: shouldShowCategories, + shouldShowAboveShowMore: isCategoryRequired, }, { item: ( @@ -718,6 +762,7 @@ function MoneyRequestConfirmationListFooter({ /> ), shouldShow: shouldShowDate, + shouldShowAboveShowMore: false, }, ...policyTagLists.map(({name}, index) => { const tagVisibilityItem = tagVisibility.at(index); @@ -751,6 +796,7 @@ function MoneyRequestConfirmationListFooter({ /> ), shouldShow, + shouldShowAboveShowMore: isTagRequired, }; }), { @@ -776,6 +822,7 @@ function MoneyRequestConfirmationListFooter({ /> ), shouldShow: shouldShowTax, + shouldShowAboveShowMore: false, }, { item: ( @@ -798,6 +845,7 @@ function MoneyRequestConfirmationListFooter({ /> ), shouldShow: shouldShowTax, + shouldShowAboveShowMore: false, }, { item: ( @@ -824,6 +872,7 @@ function MoneyRequestConfirmationListFooter({ /> ), shouldShow: shouldShowAttendees, + shouldShowAboveShowMore: false, }, { item: ( @@ -843,6 +892,7 @@ function MoneyRequestConfirmationListFooter({ ), shouldShow: shouldShowReimbursable, isSupplementary: true, + shouldShowAboveShowMore: false, }, { item: ( @@ -861,6 +911,7 @@ function MoneyRequestConfirmationListFooter({ ), shouldShow: shouldShowBillable, + shouldShowAboveShowMore: false, }, { item: ( @@ -882,6 +933,7 @@ function MoneyRequestConfirmationListFooter({ /> ), shouldShow: isPolicyExpenseChat, + shouldShowAboveShowMore: false, }, ]; @@ -944,9 +996,33 @@ function MoneyRequestConfirmationListFooter({ return badges; }, [firstDay, lastDay, translate, tripDays, icons]); - const receiptThumbnailContent = useMemo( - () => ( - + const {windowWidth} = useWindowDimensions(); + const isCompactMode = useMemo(() => !showMoreFields && isScan, [isScan, showMoreFields]); + const [receiptAspectRatio, setReceiptAspectRatio] = useState(null); + + const handleReceiptLoad = useCallback((event?: {nativeEvent: {width: number; height: number}}) => { + const width = event?.nativeEvent.width ?? 0; + const height = event?.nativeEvent.height ?? 0; + if (!width || !height) { + return; + } + const ratio = width / height; + setReceiptAspectRatio((previousRatio) => (ratio === previousRatio ? previousRatio : ratio)); + }, []); + + const receiptSizeStyle = styles.expenseViewImageSmall; + let receiptHeightStyle: ViewStyle | undefined; + let receiptResizeMode: ImageResizeMode | undefined; + const horizontalMargin = typeof styles.moneyRequestImage.marginHorizontal === 'number' ? styles.moneyRequestImage.marginHorizontal : 0; + if (isCompactMode) { + const availableWidth = windowWidth - horizontalMargin * 2; + receiptHeightStyle = getImageCompactModeStyle(variables.receiptPreviewMaxWidth, availableWidth, receiptAspectRatio); + receiptResizeMode = 'cover'; + } + + const receiptThumbnailContent = useMemo(() => { + return ( + {isLocalFile && Str.isPDF(receiptFilename) ? ( { @@ -965,12 +1041,12 @@ function MoneyRequestConfirmationListFooter({ sentryLabel={CONST.SENTRY_LABEL.REQUEST_CONFIRMATION_LIST.PDF_RECEIPT_THUMBNAIL} disabled={!shouldDisplayReceipt} disabledStyle={styles.cursorDefault} - style={styles.h100} + style={isCompactMode ? receiptHeightStyle : styles.h100} > @@ -993,7 +1069,7 @@ function MoneyRequestConfirmationListFooter({ accessibilityLabel={translate('accessibilityHints.viewAttachment')} sentryLabel={CONST.SENTRY_LABEL.REQUEST_CONFIRMATION_LIST.RECEIPT_THUMBNAIL} disabledStyle={styles.cursorDefault} - style={[styles.h100, styles.flex1]} + style={isCompactMode ? [receiptHeightStyle, styles.flex1] : [styles.h100, styles.flex1]} > )} - ), - [ - styles.moneyRequestImage, - styles.expenseViewImageSmall, - styles.cursorDefault, - styles.h100, - styles.flex1, - isLocalFile, - receiptFilename, - translate, - shouldDisplayReceipt, - resolvedReceiptImage, - onPDFLoadError, - onPDFPassword, - isThumbnail, - resolvedThumbnail, - receiptThumbnail, - fileExtension, - isDistanceRequest, - transactionID, - action, - iouType, - reportID, - isReceiptEditable, - ], - ); + ); + }, [ + styles.moneyRequestImage, + styles.cursorDefault, + styles.h100, + styles.flex1, + styles.expenseViewImageSmall, + receiptSizeStyle, + receiptHeightStyle, + handleReceiptLoad, + isCompactMode, + isLocalFile, + receiptFilename, + translate, + shouldDisplayReceipt, + resolvedReceiptImage, + onPDFLoadError, + onPDFPassword, + isThumbnail, + resolvedThumbnail, + fileExtension, + isDistanceRequest, + transactionID, + isReceiptEditable, + reportID, + action, + iouType, + shouldRequireAuthToken, + ]); // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const hasReceiptImageOrThumbnail = receiptImage || receiptThumbnail; @@ -1112,7 +1193,7 @@ function MoneyRequestConfirmationListFooter({ )} {(!shouldShowMap || isManualDistanceRequest || isOdometerDistanceRequest) && ( - + {hasReceiptImageOrThumbnail ? receiptThumbnailContent : showReceiptEmptyState && ( @@ -1124,12 +1205,44 @@ function MoneyRequestConfirmationListFooter({ Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID, Navigation.getActiveRoute())); }} - style={styles.expenseViewImageSmall} + style={isCompactMode ? receiptHeightStyle : [styles.expenseViewImageSmall, styles.receiptPreviewAspectRatio]} /> )} )} - {fields.filter((field) => field.shouldShow).map((field) => field.item)} + + + {isCompactMode && ( + + + {translate('iou.automaticallyEnterExpenseDetails')} + + )} + + {fields.filter((field) => field.shouldShow && (field.shouldShowAboveShowMore ?? false)).map((field) => field.item)} + + {!isCompactMode && fields.filter((field) => field.shouldShow && !(field.shouldShowAboveShowMore ?? false)).map((field) => {field.item})} + + {isCompactMode && fields.some((field) => field.shouldShow && !(field.shouldShowAboveShowMore ?? false)) && ( + + +