diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx index 4bf525a9b0ca..3cede0b289ea 100644 --- a/src/components/DatePicker/index.tsx +++ b/src/components/DatePicker/index.tsx @@ -48,6 +48,9 @@ function DatePicker({ forwardedFSClass, shouldDeferShowUntilPositioned = false, shouldDismissKeyboardBeforeShow = false, + rightHandSideComponent, + onPickerVisibilityChange, + shouldHideCalendarIcon = false, }: DateInputWithPickerProps) { const icons = useMemoizedLazyExpensifyIcons(['Calendar']); const styles = useThemeStyles(); @@ -101,6 +104,14 @@ function DatePicker({ [windowHeight], ); + const setPickerVisibility = useCallback( + (isVisible: boolean) => { + setIsModalVisible(isVisible); + onPickerVisibilityChange?.(isVisible); + }, + [onPickerVisibilityChange], + ); + const showDatePickerModal = useCallback(() => { cancelAutoFocus(); // Blur the date input before showing the modal, so the focus won't be returned after the modal is closed @@ -116,7 +127,7 @@ function DatePicker({ const openPicker = () => { if (!shouldDeferShowUntilPositioned) { calculatePopoverPosition(); - setIsModalVisible(true); + setPickerVisibility(true); return; } @@ -125,16 +136,16 @@ function DatePicker({ if (!openIntentRef.current) { return; } - setIsModalVisible(true); + setPickerVisibility(true); }); }; openPicker(); - }, [shouldDeferShowUntilPositioned, shouldDismissKeyboardBeforeShow, calculatePopoverPosition, cancelAutoFocus]); + }, [shouldDeferShowUntilPositioned, shouldDismissKeyboardBeforeShow, calculatePopoverPosition, cancelAutoFocus, setPickerVisibility]); const closeDatePicker = useCallback(() => { openIntentRef.current = false; - setIsModalVisible(false); + setPickerVisibility(false); if (!shouldDismissKeyboardBeforeShow) { return; @@ -143,7 +154,7 @@ function DatePicker({ textInputRef.current?.blur(); ComposerFocusManager.blurActiveInput(); Keyboard.dismiss(); - }, [shouldDismissKeyboardBeforeShow]); + }, [shouldDismissKeyboardBeforeShow, setPickerVisibility]); const handlePress = useCallback>( (event) => { @@ -222,7 +233,7 @@ function DatePicker({ ref={combinedTextInputRef} inputID={inputID} forceActiveLabel - icon={selectedDate ? null : icons.Calendar} + icon={selectedDate || shouldHideCalendarIcon ? null : icons.Calendar} iconContainerStyle={styles.pr0} label={label} accessibilityLabel={label} @@ -243,6 +254,7 @@ function DatePicker({ forwardedFSClass={forwardedFSClass} autoComplete={autoComplete} disableKeyboard + rightHandSideComponent={rightHandSideComponent} /> diff --git a/src/components/DatePicker/types.ts b/src/components/DatePicker/types.ts index 362255f2dda6..8f54da70932f 100644 --- a/src/components/DatePicker/types.ts +++ b/src/components/DatePicker/types.ts @@ -69,6 +69,19 @@ type DateInputWithPickerProps = DatePickerBaseProps & * @default false */ shouldDismissKeyboardBeforeShow?: boolean; + + /** + * Reports whether the calendar is open. Opening the picker blurs the input, so this is the signal for "the + * user is on this field" rather than `onFocus`, and it is what drives the input's focused border. + */ + onPickerVisibilityChange?: (isVisible: boolean) => void; + + /** + * Hides the trailing calendar icon the empty input shows by default. Use it when the caller renders its own + * `rightHandSideComponent` in that space and the two would otherwise sit side by side. + * @default false + */ + shouldHideCalendarIcon?: boolean; }; type DatePickerProps = { diff --git a/src/components/MoneyRequestConfirmationFields/Provider.tsx b/src/components/MoneyRequestConfirmationFields/Provider.tsx index 03490ff090a1..8fb757fa8c4f 100644 --- a/src/components/MoneyRequestConfirmationFields/Provider.tsx +++ b/src/components/MoneyRequestConfirmationFields/Provider.tsx @@ -37,6 +37,9 @@ type ProviderProps = { /** Whether we're editing an existing split expense */ isEditingSplitBill?: boolean; + /** Whether the Scan flow lets the user fill in the amount / merchant / date instead of waiting for SmartScan */ + canEnterScanFieldsManually?: boolean; + /** Whether the surface is in a policy-expense chat */ isPolicyExpenseChat?: boolean; @@ -87,6 +90,7 @@ function Provider({ isReadOnly = false, didConfirm = false, isEditingSplitBill = false, + canEnterScanFieldsManually = false, isPolicyExpenseChat = false, isScanRequest = false, isDistanceRequest = false, @@ -111,6 +115,7 @@ function Provider({ isReadOnly, didConfirm, isEditingSplitBill, + canEnterScanFieldsManually, isPolicyExpenseChat, isScanRequest, isDistanceRequest, diff --git a/src/components/MoneyRequestConfirmationFields/context.ts b/src/components/MoneyRequestConfirmationFields/context.ts index 21fa3abfae1e..1563d0403255 100644 --- a/src/components/MoneyRequestConfirmationFields/context.ts +++ b/src/components/MoneyRequestConfirmationFields/context.ts @@ -23,6 +23,8 @@ type ConfirmationFieldsContextValue = { isReadOnly: boolean; didConfirm: boolean; isEditingSplitBill: boolean; + /** Whether the Scan flow lets the user fill in the amount / merchant / date instead of waiting for SmartScan */ + canEnterScanFieldsManually: boolean; isPolicyExpenseChat: boolean; // Mode — *what kind* of expense is being confirmed diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 32d0ac1832e2..2b6e0085a0b1 100644 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -31,6 +31,7 @@ import { import type {IOUAction, IOUType} from '@src/CONST'; import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; import type * as OnyxTypes from '@src/types/onyx'; import type {Participant} from '@src/types/onyx/IOU'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; @@ -129,6 +130,21 @@ type MoneyRequestConfirmationListProps = { /** Whether we should show the amount, date, and merchant fields. */ shouldShowSmartScanFields?: boolean; + /** + * Whether the Scan flow lets the user fill in the amount / merchant / date themselves instead of waiting for + * SmartScan (new manual expense flow). Filling in any one of them makes all three required. + */ + canEnterScanFieldsManually?: boolean; + + /** + * ID of a transaction whose Scan fields are half-filled, when there is one. Multi-scan confirms every receipt at + * once, so this can be a receipt other than the one on screen, and confirmation is blocked until it is completed. + */ + halfFilledScanID?: string; + + /** Brings another of the confirmed transactions on screen, so its inline errors are the ones the user sees */ + onSwitchToTransaction?: (transactionID: string) => void; + /** A flag for verifying that the current report is a sub-report of a expense chat */ isPolicyExpenseChat?: boolean; @@ -156,6 +172,12 @@ type MoneyRequestConfirmationListProps = { type MoneyRequestConfirmationListItem = (Participant & {keyForList: string}) | OptionData; +/** + * The errors the amount / merchant / date fields render inline rather than in the footer. Raising one of these is + * only visible if those fields are on screen, so the confirmation has to reveal them when it does. + */ +const INLINE_FIELD_ERROR_KEYS = new Set(['common.error.fieldRequired', 'common.error.invalidAmount', 'iou.error.invalidMerchant']); + function MoneyRequestConfirmationList({ transaction, onSendMoney, @@ -169,6 +191,9 @@ function MoneyRequestConfirmationList({ isPerDiemRequest = false, isPolicyExpenseChat = false, shouldShowSmartScanFields = true, + canEnterScanFieldsManually = false, + halfFilledScanID, + onSwitchToTransaction, isEditingSplitBill, isReceiptEditable, selectedParticipants: selectedParticipantsProp, @@ -340,6 +365,8 @@ function MoneyRequestConfirmationList({ isEditingSplitBill, isPolicyExpenseChat, isScanRequest, + canEnterScanFieldsManually, + halfFilledScanID, shouldShowMerchant, hasSmartScanFailed, didConfirmSplit, @@ -464,11 +491,23 @@ function MoneyRequestConfirmationList({ isMovingTransactionFromTrackExpense, isTimeRequest, routeError, + canEnterScanFieldsManually, + halfFilledScanID, isReadOnly, shouldShowDate, isTaxAmountEmpty, }); + // On a multi-scan the receipt that is half-filled may not be the one on screen, so bring it into view before its + // blank fields are asked to raise the error. + const validateAndRevealFields: typeof validate = (paymentType) => { + const result = validate(paymentType); + if (result?.errorKey && INLINE_FIELD_ERROR_KEYS.has(result.errorKey) && halfFilledScanID && halfFilledScanID !== transactionID) { + onSwitchToTransaction?.(halfFilledScanID); + } + return result; + }; + const confirm = buildConfirmAction({ iouType, policy, @@ -476,7 +515,7 @@ function MoneyRequestConfirmationList({ routeError, formError, isDelegateAccessRestricted, - validate, + validate: validateAndRevealFields, setFormError, setDidConfirmSplit, showDelegateNoAccessModal, @@ -489,6 +528,16 @@ function MoneyRequestConfirmationList({ onSendMoney, }); + // The amount / merchant / date render these errors inline, and compact mode keeps those fields behind "Show more", + // so an outstanding one has to open the section or pressing Create looks like it did nothing. Opening it during + // render rather than from the press keeps it open when a multi-scan switches to the half-filled receipt, since + // that remounts and resets the flag. Writing the flag itself (rather than reading the error alongside it) keeps + // the section open once the user starts filling the fields in and the error clears, and keeps the receipt sizing, + // which reads the same flag, from disagreeing with what is on screen. + if (INLINE_FIELD_ERROR_KEYS.has(formError) && !showMoreFields) { + setShowMoreFields(true); + } + const isCompactMode = !showMoreFields && isScanRequest && !isInLandscapeMode; const selectionListStyle = { containerStyle: [styles.flexBasisAuto], @@ -528,6 +577,7 @@ function MoneyRequestConfirmationList({ isReadOnly={isReadOnly} didConfirm={!!didConfirm} isEditingSplitBill={isEditingSplitBill} + canEnterScanFieldsManually={canEnterScanFieldsManually} isPolicyExpenseChat={isPolicyExpenseChat} isScanRequest={isScanRequest} isDistanceRequest={isDistanceRequest} diff --git a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts index 9ad71cb5aa46..60dd6ebadf11 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts @@ -16,6 +16,7 @@ import { getTaxAmount, hasTaxRateWithMatchingValue, isMerchantMissing, + isPartiallyEnteredScanExpense, isScanRequest as isScanRequestUtil, } from '@libs/TransactionUtils'; import {isValidInputLength} from '@libs/ValidationUtils'; @@ -113,6 +114,15 @@ type UseConfirmationValidationParams = { /** Truthy when the route to the confirmation page has a known error */ routeError: string | null | undefined; + /** Whether the Scan flow lets the user fill in the amount / merchant / date instead of waiting for SmartScan */ + canEnterScanFieldsManually: boolean; + + /** + * ID of a half-filled Scan among the transactions being confirmed, when there is one. Multi-scan confirms every + * receipt at once, so this can name a receipt other than the one being validated here. + */ + halfFilledScanID?: string; + /** Whether the confirmation fields are read-only (date is not inline-editable) */ isReadOnly: boolean; @@ -165,12 +175,18 @@ function useConfirmationValidation({ isMovingTransactionFromTrackExpense, isTimeRequest, routeError, + canEnterScanFieldsManually, + halfFilledScanID, isReadOnly, shouldShowDate, isTaxAmountEmpty, }: UseConfirmationValidationParams): {validate: (paymentType?: PaymentMethodType) => ValidationResult | null} { const {getCurrencyDecimals} = useCurrencyListActions(); const selectedParticipantsCount = selectedParticipants.length; + // The Scan confirmation reveals the amount / merchant / date fields behind "Show more". Each is optional there: + // a field left blank is still read off the receipt, but one the user does fill in is subject to the same + // validation as a manually entered one. + const shouldValidateEnteredAmount = transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL || canEnterScanFieldsManually; const validate = (paymentType?: PaymentMethodType): ValidationResult | null => { if (!!routeError || !transactionID) { return null; @@ -187,13 +203,25 @@ function useConfirmationValidation({ if (!isScanRequestUtil(transaction) && !isTimeRequest && !isDistanceRequest && iouAmount === 0 && isP2P) { return {errorKey: 'common.error.invalidAmount'}; } - if (isConfirmationAmountMissing(transaction)) { + // `isConfirmationAmountMissing` only applies to manually entered amounts. Per diem, distance and time set the + // amount programmatically, and a scan reads it off the receipt whenever the user leaves the field blank. + if (isConfirmationAmountMissing(transaction, canEnterScanFieldsManually)) { + return {errorKey: 'common.error.fieldRequired'}; + } + // The amount / merchant / date the Scan confirmation reveals are all-or-nothing. Leaving all three blank hands + // the expense to SmartScan and filling all three in submits it as a manual expense, but a half-filled set is + // neither, so it is blocked here and each blank field raises the same error inline. + if (isPartiallyEnteredScanExpense(transaction, canEnterScanFieldsManually)) { + return {errorKey: 'common.error.fieldRequired'}; + } + // On a multi-scan the same rule has to hold for the receipts that are not on screen, since Create submits + // all of them at once. The caller brings the offending one into view so its blank fields raise this inline. + if (halfFilledScanID) { return {errorKey: 'common.error.fieldRequired'}; } if ( - transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL && + shouldValidateEnteredAmount && transaction?.isAmountSet && - !isScanRequestUtil(transaction) && !isTimeRequest && !isDistanceRequest && !isEditingSplitBill && @@ -203,7 +231,7 @@ function useConfirmationValidation({ } // The date is an inline, clearable required field for every type that shows it (manual, distance, time, // invoice, ...). Block confirmation when the user cleared it. - if (isConfirmationDateMissing(transaction, shouldShowDate, isReadOnly)) { + if (isConfirmationDateMissing(transaction, shouldShowDate, isReadOnly, canEnterScanFieldsManually)) { return {errorKey: 'common.error.fieldRequired'}; } const merchantValue = iouMerchant ?? ''; diff --git a/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts b/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts index 752f6eeb3c25..e5ad0136841c 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts @@ -1,7 +1,7 @@ import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; -import {isConfirmationAmountMissing, isConfirmationDateMissing} from '@libs/MoneyRequestUtils'; +import {isConfirmationAmountMissing, isConfirmationDateMissing, isConfirmationMerchantMissing} from '@libs/MoneyRequestUtils'; import {isAttendeeTrackingEnabled} from '@libs/PolicyUtils'; import {areRequiredFieldsEmpty, getTag, hasMissingSmartscanFields, isMerchantMissing} from '@libs/TransactionUtils'; import {isInvalidMerchantValue, isUntypedPlaceholderMerchant, isValidInputLength} from '@libs/ValidationUtils'; @@ -54,6 +54,15 @@ type UseFormErrorManagementParams = { /** Whether the IOU was started from a SmartScan flow */ isScanRequest: boolean; + /** Whether the Scan confirmation lets the user fill in the amount / merchant / date themselves */ + canEnterScanFieldsManually: boolean; + + /** + * ID of a half-filled Scan among the transactions being confirmed, when there is one. On a multi-scan it can name + * a receipt other than the one on screen, which is why the required error must not be cleared against this one. + */ + halfFilledScanID?: string; + /** Whether the merchant field should be visible in the UI */ shouldShowMerchant: boolean; @@ -138,6 +147,8 @@ function useFormErrorManagement({ isEditingSplitBill, isPolicyExpenseChat, isScanRequest, + canEnterScanFieldsManually, + halfFilledScanID, shouldShowMerchant, hasSmartScanFailed, didConfirmSplit, @@ -215,14 +226,18 @@ function useFormErrorManagement({ // These reuse the very predicates `useConfirmationValidation` raises `common.error.fieldRequired` from, so the // clear side can never drift from the validation side and strand a required error that can no longer be cleared (#96568). - const isAmountRequiredMissing = isConfirmationAmountMissing(transaction); - const isDateRequiredMissing = isConfirmationDateMissing(transaction, shouldShowDate, isReadOnly); + const isAmountRequiredMissing = isConfirmationAmountMissing(transaction, canEnterScanFieldsManually); + const isDateRequiredMissing = isConfirmationDateMissing(transaction, shouldShowDate, isReadOnly, canEnterScanFieldsManually); + const isMerchantRequiredMissing = isConfirmationMerchantMissing(transaction, canEnterScanFieldsManually); useEffect(() => { - if (formErrorRef.current !== 'common.error.fieldRequired' || isAmountRequiredMissing || isDateRequiredMissing) { + // `halfFilledScanID` keeps the error alive while any other receipt of a multi-scan is still half-filled. The + // predicates above only see the transaction on screen, so without it the error would clear the moment a + // complete receipt is displayed, including during the render it takes to switch to the incomplete one. + if (formErrorRef.current !== 'common.error.fieldRequired' || isAmountRequiredMissing || isDateRequiredMissing || isMerchantRequiredMissing || !!halfFilledScanID) { return; } setFormError(''); - }, [isAmountRequiredMissing, isDateRequiredMissing, setFormError]); + }, [isAmountRequiredMissing, isDateRequiredMissing, isMerchantRequiredMissing, halfFilledScanID, setFormError]); useEffect(() => { const currentFormError = formErrorRef.current; diff --git a/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx b/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx index 10819d0b7cf4..6993284ddcf1 100644 --- a/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx @@ -16,7 +16,7 @@ import {calculateAmount, isMovingTransactionFromTrackExpense, isParticipantP2P} import {isConfirmationAmountMissing} from '@libs/MoneyRequestUtils'; import Navigation from '@libs/Navigation/Navigation'; import {shouldEnableNegative} from '@libs/ReportUtils'; -import {calculateTaxAmount, getTaxCode, getTaxValue} from '@libs/TransactionUtils'; +import {calculateTaxAmount, getTaxCode, getTaxValue, hasAnyManuallyEnteredScanField} from '@libs/TransactionUtils'; import IOURequestStepCurrencyModal from '@pages/iou/request/step/IOURequestStepCurrencyModal'; @@ -33,6 +33,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; +import AutomaticFieldHint from './AutomaticFieldHint'; import {amountSliceSelector} from './selectors'; import useTransactionSelector from './useTransactionSelector'; @@ -65,8 +66,10 @@ function AmountField({ setFormError, isParticipantPickerVisible = false, }: AmountFieldProps) { - const shouldAutoFocusOnMount = !canUseTouchScreen(); - const {isEditingSplitBill, isReadOnly, didConfirm, transactionID, action, iouType, reportID, reportActionID} = useConfirmationFields(); + const {isEditingSplitBill, canEnterScanFieldsManually, isReadOnly, didConfirm, transactionID, action, iouType, reportID, reportActionID} = useConfirmationFields(); + // The Scan confirmation keeps the amount unfocused: its fields sit behind "Show more", which the user also opens + // to reach the rest of the expense, so focusing the amount would push them towards entering it manually. + const shouldAutoFocusOnMount = !canUseTouchScreen() && !canEnterScanFieldsManually; const styles = useThemeStyles(); const {translate, preferredLocale} = useLocalize(); const {getCurrencyDecimals, getCurrencySymbol} = useCurrencyListActions(); @@ -82,14 +85,15 @@ function AmountField({ const amountIsMissing = transactionSlice?.isAmountMissing ?? false; const [isCurrencyPickerVisible, setIsCurrencyPickerVisible] = useState(false); + const [isAmountInputFocused, setIsAmountInputFocused] = useState(false); const isAmountFieldDisabled = didConfirm || isReadOnly || shouldShowTimeRequestFields || isDistanceRequest; const isP2P = isParticipantP2P(getMoneyRequestParticipantsFromReport(report, currentUserPersonalDetails.accountID).at(0)); // `common.error.fieldRequired` is shared with the date field, so only surface it on the amount input when the // amount itself is the missing value. `isConfirmationAmountMissing` is the same predicate validation raises the - // error from, so a scan expense (where the amount is populated programmatically and `isAmountSet` is never set) - // can't show a phantom required error under a perfectly good amount. - const shouldShowAmountRequiredError = formError === 'common.error.fieldRequired' && isConfirmationAmountMissing(transactionSlice); + // error from, so a scan expense (where the amount is read off the receipt whenever the user leaves the field + // blank) can't show a phantom required error under a field that is deliberately empty. + const shouldShowAmountRequiredError = formError === 'common.error.fieldRequired' && isConfirmationAmountMissing(transactionSlice, canEnterScanFieldsManually); const shouldShowAmountInvalidError = formError === 'common.error.invalidAmount'; let amountFieldErrorText = ''; @@ -103,10 +107,20 @@ function AmountField({ const decimals = getCurrencyDecimals(effectiveCurrency); // In the manual expense flow the amount field starts empty (transaction.amount defaults to 0 before the user // touches it). Once the user explicitly sets an amount – including 0 – isAmountSet becomes true and we show the - // real value. This avoids showing "$0.00" as a pre-filled default. Scan and other non-manual flows populate - // amount programmatically and never set isAmountSet. - const shouldShowEmptyAmount = !transactionSlice?.isAmountSet && transactionSlice?.iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL; + // real value. This avoids showing "$0.00" as a pre-filled default. The Scan flow behaves the same way: its amount + // belongs to the receipt, so the field is empty until the user chooses to enter one instead of waiting for + // SmartScan. Per diem, distance and time flows populate the amount programmatically and never set isAmountSet. + const shouldShowEmptyAmount = !transactionSlice?.isAmountSet && (transactionSlice?.iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL || canEnterScanFieldsManually); const transactionAmount = shouldShowEmptyAmount ? '' : convertToFrontendAmountAsString(amount, decimals); + // While the Scan confirmation is still waiting on SmartScan for this field, it says so instead of sitting empty. + // Focusing the field is the user taking it over, so the hint goes as soon as that happens rather than waiting for + // the first keystroke. It would otherwise sit next to the caret promising to fill in what is being typed. + // Entering any one of the three fields drops the hint from all of them, since that is the point where the expense + // stops being scanned and the other two become the user's to fill in as well. + const shouldShowAutomaticHint = canEnterScanFieldsManually && !isAmountInputFocused && !hasAnyManuallyEnteredScanField(transactionSlice); + // The hint and the flip / currency buttons share the right-hand side of the input, so the field shows one or the + // other. The buttons come back as soon as the amount is the user's to enter. + const shouldShowAmountButtons = !shouldShowAutomaticHint; const allowNegative = shouldEnableNegative(report, policy, iouType, transactionSlice?.participants); // `autoFocus` on our TextInput only runs on mount. Closing and reopening the RHP often keeps the same mounted @@ -298,6 +312,7 @@ function AmountField({ {!isAmountFieldDisabled ? ( { + setIsAmountInputFocused(true); + }} + onBlur={() => { + setIsAmountInputFocused(false); + }} + leadingRightHandSideComponent={shouldShowAutomaticHint ? : undefined} disabled={isAmountFieldDisabled} /> diff --git a/src/components/MoneyRequestConfirmationList/sections/AutomaticFieldHint.tsx b/src/components/MoneyRequestConfirmationList/sections/AutomaticFieldHint.tsx new file mode 100644 index 000000000000..dddbe0df3ea4 --- /dev/null +++ b/src/components/MoneyRequestConfirmationList/sections/AutomaticFieldHint.tsx @@ -0,0 +1,38 @@ +/** + * The "Automatic" hint the Scan confirmation shows inside the amount, merchant and date fields while SmartScan is + * still the one filling them in. It mirrors the right label the category field carries for the same promise, and + * each field drops it as soon as the user takes the field over, on focus or once it has a value of its own. + */ +import Icon from '@components/Icon'; +import Text from '@components/Text'; + +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import variables from '@styles/variables'; + +import React from 'react'; +import {View} from 'react-native'; + +function AutomaticFieldHint() { + const styles = useThemeStyles(); + const theme = useTheme(); + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['Sparkles']); + + return ( + + + {translate('common.automatic')} + + ); +} + +export default AutomaticFieldHint; diff --git a/src/components/MoneyRequestConfirmationList/sections/DateField.tsx b/src/components/MoneyRequestConfirmationList/sections/DateField.tsx index ba476f3a6560..a7b023706b47 100644 --- a/src/components/MoneyRequestConfirmationList/sections/DateField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/DateField.tsx @@ -10,11 +10,12 @@ import usePolicy from '@hooks/usePolicy'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import useThemeStyles from '@hooks/useThemeStyles'; -import {setMoneyRequestCreated, updateDistanceRateOnExpenseDateChange} from '@libs/actions/IOU/MoneyRequest'; +import {clearMoneyRequestCreated, setMoneyRequestCreated, updateDistanceRateOnExpenseDateChange} from '@libs/actions/IOU/MoneyRequest'; import {shouldUseTransactionDraft} from '@libs/IOUUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import {isPolicyExpenseChat as isPolicyExpenseChatReportUtil} from '@libs/ReportUtils'; +import {hasAnyManuallyEnteredScanField, isPartiallyEnteredScanExpense} from '@libs/TransactionUtils'; import {setDraftSplitTransaction} from '@userActions/IOU/Split'; @@ -25,9 +26,10 @@ import {DYNAMIC_ROUTES} from '@src/ROUTES'; import INPUT_IDS from '@src/types/form/MoneyRequestDateForm'; import {format} from 'date-fns'; -import React from 'react'; +import React, {useState} from 'react'; import {View} from 'react-native'; +import AutomaticFieldHint from './AutomaticFieldHint'; import {dateStateSelector} from './selectors'; import useTransactionSelector from './useTransactionSelector'; @@ -45,7 +47,7 @@ type DateFieldProps = { function DateField({shouldDisplayFieldError, didConfirm, isReadOnly, formError, transactionID, action, iouType, reportID, reportActionID}: DateFieldProps) { const {getCurrencyDecimals, getCurrencySymbol} = useCurrencyListActions(); - const {isEditingSplitBill} = useConfirmationFields(); + const {isEditingSplitBill, canEnterScanFieldsManually} = useConfirmationFields(); const styles = useThemeStyles(); const {translate} = useLocalize(); const isTrackExpense = iouType === CONST.IOU.TYPE.TRACK; @@ -65,16 +67,32 @@ function DateField({shouldDisplayFieldError, didConfirm, isReadOnly, formError, const createdMissing = dateState?.isMissing ?? true; const transactionHasReceipt = dateState?.hasReceipt ?? false; + // A draft is seeded with today's date, but in the Scan flow the date belongs to the receipt, not to today, so the + // picker stays empty until the user picks one, the same way the amount field starts empty. + const shouldShowEmptyDate = canEnterScanFieldsManually && !dateState?.isCreatedSet; + + // Opening the calendar blurs the input, so the open picker is this field's "the user is on it" signal rather + // than focus, and it is what draws the focused border. The hint follows it so it can't sit next to an open + // calendar promising to fill in the date the user is picking. Entering any one of the three fields drops the hint + // from all of them, since that is the point where the expense stops being scanned. + const [isDatePickerOpen, setIsDatePickerOpen] = useState(false); + const shouldShowAutomaticHint = shouldShowEmptyDate && !isDatePickerOpen && !hasAnyManuallyEnteredScanField(dateState); + const dateErrorText = shouldDisplayFieldError && createdMissing ? translate('common.error.enterDate') : ''; - const inlineDateErrorText = formError === 'common.error.fieldRequired' && createdMissing ? translate('common.error.fieldRequired') : ''; + // On a half-filled Scan the date is required even though it is never blank in the draft, so the all-or-nothing + // predicate stands in for `createdMissing` there. + const isDateRequiredMissing = isPartiallyEnteredScanExpense(dateState, canEnterScanFieldsManually) ? !dateState?.isCreatedSet : createdMissing; + const inlineDateErrorText = formError === 'common.error.fieldRequired' && isDateRequiredMissing ? translate('common.error.fieldRequired') : ''; const handleDateChange = (newDate: string) => { if (!transactionID) { return; } - if (newDate === iouCreated) { + // While the picker renders empty the persisted date is only a default, so a pick that matches it still has to + // be written. That write is what marks the date as chosen by the user. + if (newDate === iouCreated && !shouldShowEmptyDate) { return; } @@ -83,6 +101,13 @@ function DateField({shouldDisplayFieldError, didConfirm, isReadOnly, formError, return; } + // Clearing the date on a scan hands the field back to SmartScan rather than emptying it, the same way clearing + // the amount or the merchant does. + if (!newDate && canEnterScanFieldsManually) { + clearMoneyRequestCreated(transactionID, shouldUseTransactionDraft(action)); + return; + } + setMoneyRequestCreated(transactionID, newDate, shouldUseTransactionDraft(action), transactionHasReceipt); if (action !== CONST.IOU.ACTION.EDIT) { @@ -108,7 +133,7 @@ function DateField({shouldDisplayFieldError, didConfirm, isReadOnly, formError, : undefined} + // The calendar icon and the hint share the right-hand side, so the field shows one or the other. + // The icon comes back once the user opens the picker, the same way the amount field's buttons do. + shouldHideCalendarIcon={shouldShowAutomaticHint} + onPickerVisibilityChange={setIsDatePickerOpen} /> ); diff --git a/src/components/MoneyRequestConfirmationList/sections/MerchantField.tsx b/src/components/MoneyRequestConfirmationList/sections/MerchantField.tsx index 3467995a3e18..ebcfaa46c1f3 100644 --- a/src/components/MoneyRequestConfirmationList/sections/MerchantField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/MerchantField.tsx @@ -8,8 +8,10 @@ import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; import {clearMoneyRequestMerchant, setMoneyRequestMerchant} from '@libs/actions/IOU/MoneyRequest'; +import {isConfirmationMerchantMissing} from '@libs/MoneyRequestUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; +import {hasAnyManuallyEnteredScanField} from '@libs/TransactionUtils'; import {isUntypedPlaceholderMerchant, isValidInputLength} from '@libs/ValidationUtils'; import {setDraftSplitTransaction} from '@userActions/IOU/Split'; @@ -21,6 +23,7 @@ import {DYNAMIC_ROUTES} from '@src/ROUTES'; import React, {useState} from 'react'; import {View} from 'react-native'; +import AutomaticFieldHint from './AutomaticFieldHint'; import {merchantStateSelector} from './selectors'; import useTransactionSelector from './useTransactionSelector'; @@ -31,7 +34,7 @@ type MerchantFieldProps = { }; function MerchantField({isMerchantRequired, shouldDisplayFieldError, formError}: MerchantFieldProps) { - const {action, iouType, transactionID, reportID, reportActionID, isReadOnly, didConfirm, isEditingSplitBill} = useConfirmationFields(); + const {action, iouType, transactionID, reportID, reportActionID, isReadOnly, didConfirm, isEditingSplitBill, canEnterScanFieldsManually} = useConfirmationFields(); const styles = useThemeStyles(); const {translate} = useLocalize(); const {getCurrencyDecimals, getCurrencySymbol} = useCurrencyListActions(); @@ -51,6 +54,13 @@ function MerchantField({isMerchantRequired, shouldDisplayFieldError, formError}: const [prevDisplayValue, setPrevDisplayValue] = useState(displayMerchantValue); const [prevTransactionID, setPrevTransactionID] = useState(transactionID); + // While the Scan confirmation is still waiting on SmartScan for this field, it says so instead of sitting empty. + // Focusing the field is the user taking it over, so the hint goes as soon as that happens rather than waiting for + // the first keystroke. It would otherwise sit next to the caret promising to fill in what is being typed. + // Entering any one of the three fields drops the hint from all of them, since that is the point where the expense + // stops being scanned and the other two become the user's to fill in as well. + const shouldShowAutomaticHint = canEnterScanFieldsManually && !isMerchantInputFocused && !hasAnyManuallyEnteredScanField(merchantState); + // Sync the mirror during render (not in an effect) to avoid an extra render pass. Reset on transaction change // even while focused; otherwise sync external updates (SmartScan, drafts) only when the field isn't being edited. if (transactionID !== prevTransactionID) { @@ -76,6 +86,13 @@ function MerchantField({isMerchantRequired, shouldDisplayFieldError, formError}: return translate('iou.error.invalidMerchant'); } + // `common.error.fieldRequired` is shared with the amount and date fields, so only surface it here when the + // merchant is the required value that is still missing. On a half-filled Scan it is required even though the + // surface does not otherwise demand a merchant, because the three fields are all-or-nothing there. + if (formError === 'common.error.fieldRequired' && (isConfirmationMerchantMissing(merchantState, canEnterScanFieldsManually) || (isMerchantRequired && !displayMerchantValue))) { + return translate('common.error.fieldRequired'); + } + if (shouldDisplayFieldError && isMerchantRequired && !displayMerchantValue) { return translate('common.error.fieldRequired'); } @@ -127,6 +144,7 @@ function MerchantField({isMerchantRequired, shouldDisplayFieldError, formError}: label={translate('common.merchant')} accessibilityLabel={translate('common.merchant')} errorText={merchantErrorText} + rightHandSideComponent={shouldShowAutomaticHint ? : undefined} /> ); diff --git a/src/components/MoneyRequestConfirmationList/sections/selectors.ts b/src/components/MoneyRequestConfirmationList/sections/selectors.ts index cab1dd37b315..6afd59c09990 100644 --- a/src/components/MoneyRequestConfirmationList/sections/selectors.ts +++ b/src/components/MoneyRequestConfirmationList/sections/selectors.ts @@ -23,7 +23,16 @@ type Transaction = OnyxTypes.Transaction; // --- DateField --- -type DateState = {iouCreated: string; isMissing: boolean; hasReceipt: boolean}; +type DateState = { + iouCreated: string; + isMissing: boolean; + hasReceipt: boolean; + isCreatedSet: boolean; + // The Scan confirmation's amount / merchant / date are all-or-nothing, so the date field reads the other two. + iouRequestType: Transaction['iouRequestType']; + isAmountSet: boolean; + isMerchantSet: boolean; +}; const dateStateSelector = (t: OnyxEntry): DateState | undefined => { if (!t) { @@ -33,6 +42,10 @@ const dateStateSelector = (t: OnyxEntry): DateState | undefined => iouCreated: getCreated(t), isMissing: isCreatedMissing(t), hasReceipt: hasReceipt(t), + isCreatedSet: t.isCreatedSet ?? false, + iouRequestType: t.iouRequestType, + isAmountSet: t.isAmountSet ?? false, + isMerchantSet: t.isMerchantSet ?? false, }; }; @@ -109,7 +122,16 @@ const categoryStateSelector = (t: OnyxEntry): CategoryState | undef // --- MerchantField --- -type MerchantState = {merchant: string; isMerchantSet: boolean; isMissing: boolean; hasReceipt: boolean}; +type MerchantState = { + merchant: string; + isMerchantSet: boolean; + isMissing: boolean; + hasReceipt: boolean; + // The Scan confirmation's amount / merchant / date are all-or-nothing, so the merchant field reads the other two. + iouRequestType: Transaction['iouRequestType']; + isAmountSet: boolean; + isCreatedSet: boolean; +}; const merchantStateSelector = (t: OnyxEntry): MerchantState | undefined => { if (!t) { @@ -120,6 +142,9 @@ const merchantStateSelector = (t: OnyxEntry): MerchantState | undef isMerchantSet: t.isMerchantSet ?? false, isMissing: isMerchantMissing(t), hasReceipt: hasReceipt(t), + iouRequestType: t.iouRequestType, + isAmountSet: t.isAmountSet ?? false, + isCreatedSet: t.isCreatedSet ?? false, }; }; @@ -150,6 +175,9 @@ type AmountSlice = { isAmountMissing: boolean; isAmountSet: Transaction['isAmountSet']; taxCode: Transaction['taxCode']; + // The Scan confirmation's amount / merchant / date are all-or-nothing, so the amount field reads the other two. + isMerchantSet: boolean; + isCreatedSet: boolean; }; const amountSliceSelector = (t: OnyxEntry): AmountSlice | undefined => { @@ -167,6 +195,8 @@ const amountSliceSelector = (t: OnyxEntry): AmountSlice | undefined isAmountMissing: isAmountMissing(t), isAmountSet: t.isAmountSet, taxCode: t.taxCode, + isMerchantSet: t.isMerchantSet ?? false, + isCreatedSet: t.isCreatedSet ?? false, }; }; diff --git a/src/components/NumberWithSymbolForm.tsx b/src/components/NumberWithSymbolForm.tsx index 624d19658b55..f93e6bf195f8 100644 --- a/src/components/NumberWithSymbolForm.tsx +++ b/src/components/NumberWithSymbolForm.tsx @@ -106,6 +106,9 @@ type NumberWithSymbolFormProps = { /** Whether to show the currency selection button */ shouldShowCurrencyButton?: boolean; + /** Extra content rendered at the start of the right-hand side, before the flip and currency buttons. `displayAsTextInput` mode only. */ + leadingRightHandSideComponent?: React.ReactNode; + onCurrencyButtonPress?: () => void; /** @@ -184,6 +187,7 @@ function NumberWithSymbolForm({ onSubmitEditing, shouldShowFlipButton = false, shouldShowCurrencyButton = false, + leadingRightHandSideComponent, onCurrencyButtonPress, currencyButtonLabel, currencyButtonAccessibilityLabel, @@ -483,6 +487,7 @@ function NumberWithSymbolForm({ const textInputRightHandSideComponent = useMemo(() => { return ( + {leadingRightHandSideComponent} {shouldShowFlipButton && allowNegativeInput && canUseTouchScreen && (