diff --git a/src/CONST.ts b/src/CONST.ts index cd4d2b24e97..ad8832166db 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -189,6 +189,8 @@ const CONST = { }, // Multiplier for gyroscope animation in order to make it a bit more subtle ANIMATION_GYROSCOPE_VALUE: 0.4, + ANIMATION_PAY_BUTTON_DURATION: 200, + ANIMATION_PAY_BUTTON_HIDE_DELAY: 1000, BACKGROUND_IMAGE_TRANSITION_DURATION: 1000, SCREEN_TRANSITION_END_TIMEOUT: 1000, ARROW_HIDE_DELAY: 3000, diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx index 9e67de51c47..34710a2d897 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -80,6 +80,9 @@ type ButtonProps = Partial & { /** Additional styles to add after local styles. Applied to Pressable portion of button */ style?: StyleProp; + /** Additional styles to add to the component when it's disabled */ + disabledStyle?: StyleProp; + /** Additional button styles. Specific to the OpacityView of the button */ innerStyles?: StyleProp; @@ -206,6 +209,7 @@ function Button( enterKeyEventListenerPriority = 0, style = [], + disabledStyle, innerStyles = [], textStyles = [], textHoverStyles = [], @@ -381,6 +385,7 @@ function Button( danger && !isDisabled ? styles.buttonDangerHovered : undefined, hoverStyles, ]} + disabledStyle={disabledStyle} id={id} accessibilityLabel={accessibilityLabel} role={CONST.ROLE.BUTTON} diff --git a/src/components/ButtonWithDropdownMenu/index.tsx b/src/components/ButtonWithDropdownMenu/index.tsx index 1ff0eaae472..c1b662c3680 100644 --- a/src/components/ButtonWithDropdownMenu/index.tsx +++ b/src/components/ButtonWithDropdownMenu/index.tsx @@ -25,6 +25,7 @@ function ButtonWithDropdownMenu({ menuHeaderText = '', customText, style, + disabledStyle, buttonSize = CONST.DROPDOWN_BUTTON_SIZE.MEDIUM, anchorAlignment = { horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT, @@ -157,6 +158,7 @@ function ButtonWithDropdownMenu({ pressOnEnter={pressOnEnter} isDisabled={isDisabled || !!options[0].disabled} style={[styles.w100, style]} + disabledStyle={disabledStyle} isLoading={isLoading} text={selectedItem.text} onPress={(event) => onPress(event, options[0].value)} diff --git a/src/components/ButtonWithDropdownMenu/types.ts b/src/components/ButtonWithDropdownMenu/types.ts index 59bfd74cd55..da6b3d93433 100644 --- a/src/components/ButtonWithDropdownMenu/types.ts +++ b/src/components/ButtonWithDropdownMenu/types.ts @@ -68,6 +68,9 @@ type ButtonWithDropdownMenuProps = { /** Additional styles to add to the component */ style?: StyleProp; + /** Additional styles to add to the component when it's disabled */ + disabledStyle?: StyleProp; + /** Menu options to display */ /** e.g. [{text: 'Pay with Expensify', icon: Wallet}] */ options: Array>; diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx index 0901004481f..367defc4ff4 100644 --- a/src/components/ReportActionItem/ReportPreview.tsx +++ b/src/components/ReportActionItem/ReportPreview.tsx @@ -1,5 +1,5 @@ import truncate from 'lodash/truncate'; -import React, {useMemo, useState} from 'react'; +import React, {useCallback, useMemo, useState} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; @@ -12,7 +12,7 @@ import OfflineWithFeedback from '@components/OfflineWithFeedback'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import ProcessMoneyReportHoldMenu from '@components/ProcessMoneyReportHoldMenu'; import type {ActionHandledType} from '@components/ProcessMoneyReportHoldMenu'; -import SettlementButton from '@components/SettlementButton'; +import AnimatedSettlementButton from '@components/SettlementButton/AnimatedSettlementButton'; import {showContextMenuForReport} from '@components/ShowContextMenuContext'; import Text from '@components/Text'; import useDelegateUserDetails from '@hooks/useDelegateUserDetails'; @@ -23,6 +23,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import ControlSelection from '@libs/ControlSelection'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; +import HapticFeedback from '@libs/HapticFeedback'; import Navigation from '@libs/Navigation/Navigation'; import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReceiptUtils from '@libs/ReceiptUtils'; @@ -136,6 +137,7 @@ function ReportPreview({ [transactions, iouReportID, action], ); + const [isPaidAnimationRunning, setIsPaidAnimationRunning] = useState(false); const [isHoldMenuVisible, setIsHoldMenuVisible] = useState(false); const [requestType, setRequestType] = useState(); const [nonHeldAmount, fullAmount] = ReportUtils.getNonHeldAndFullAmount(iouReport, policy); @@ -196,6 +198,7 @@ function ReportPreview({ const {isDelegateAccessRestricted, delegatorEmail} = useDelegateUserDetails(); const [isNoDelegateAccessMenuVisible, setIsNoDelegateAccessMenuVisible] = useState(false); + const stopAnimation = useCallback(() => setIsPaidAnimationRunning(false), []); const confirmPayment = (type: PaymentMethodType | undefined, payAsBusiness?: boolean) => { if (!type) { return; @@ -207,6 +210,8 @@ function ReportPreview({ } else if (ReportUtils.hasHeldExpenses(iouReport?.reportID)) { setIsHoldMenuVisible(true); } else if (chatReport && iouReport) { + setIsPaidAnimationRunning(true); + HapticFeedback.longPress(); if (ReportUtils.isInvoiceReport(iouReport)) { IOU.payInvoice(type, chatReport, iouReport, payAsBusiness); } else { @@ -306,7 +311,10 @@ function ReportPreview({ const bankAccountRoute = ReportUtils.getBankAccountRoute(chatReport); - const shouldShowPayButton = useMemo(() => IOU.canIOUBePaid(iouReport, chatReport, policy, allTransactions), [iouReport, chatReport, policy, allTransactions]); + const shouldShowPayButton = useMemo( + () => isPaidAnimationRunning || IOU.canIOUBePaid(iouReport, chatReport, policy, allTransactions), + [isPaidAnimationRunning, iouReport, chatReport, policy, allTransactions], + ); const shouldShowApproveButton = useMemo(() => IOU.canApproveIOU(iouReport, policy), [iouReport, policy]); @@ -473,7 +481,9 @@ function ReportPreview({ {shouldShowSettlementButton && ( - void; +}; + +function AnimatedSettlementButton({isPaidAnimationRunning, onAnimationFinish, isDisabled, ...settlementButtonProps}: AnimatedSettlementButtonProps) { + const styles = useThemeStyles(); + const buttonScale = useSharedValue(1); + const buttonOpacity = useSharedValue(1); + const paymentCompleteTextScale = useSharedValue(0); + const paymentCompleteTextOpacity = useSharedValue(1); + const height = useSharedValue(variables.componentSizeNormal); + const buttonStyles = useAnimatedStyle(() => ({ + transform: [{scale: buttonScale.value}], + opacity: buttonOpacity.value, + })); + const paymentCompleteTextStyles = useAnimatedStyle(() => ({ + transform: [{scale: paymentCompleteTextScale.value}], + opacity: paymentCompleteTextOpacity.value, + position: 'absolute', + alignSelf: 'center', + })); + const containerStyles = useAnimatedStyle(() => ({ + height: height.value, + justifyContent: 'center', + overflow: 'hidden', + })); + const buttonDisabledStyle = isPaidAnimationRunning + ? { + opacity: 1, + ...styles.cursorDefault, + } + : undefined; + + const resetAnimation = useCallback(() => { + // eslint-disable-next-line react-compiler/react-compiler + buttonScale.value = 1; + buttonOpacity.value = 1; + paymentCompleteTextScale.value = 0; + paymentCompleteTextOpacity.value = 1; + height.value = variables.componentSizeNormal; + }, [buttonScale, buttonOpacity, paymentCompleteTextScale, paymentCompleteTextOpacity, height]); + + useEffect(() => { + if (!isPaidAnimationRunning) { + resetAnimation(); + return; + } + // eslint-disable-next-line react-compiler/react-compiler + buttonScale.value = withTiming(0, {duration: CONST.ANIMATION_PAY_BUTTON_DURATION}); + buttonOpacity.value = withTiming(0, {duration: CONST.ANIMATION_PAY_BUTTON_DURATION}); + paymentCompleteTextScale.value = withTiming(1, {duration: CONST.ANIMATION_PAY_BUTTON_DURATION}); + + // Wait for the above animation + 1s delay before hiding the component + const totalDelay = CONST.ANIMATION_PAY_BUTTON_DURATION + CONST.ANIMATION_PAY_BUTTON_HIDE_DELAY; + height.value = withDelay( + totalDelay, + withTiming(0, {duration: CONST.ANIMATION_PAY_BUTTON_DURATION}, () => runOnJS(onAnimationFinish)()), + ); + paymentCompleteTextOpacity.value = withDelay(totalDelay, withTiming(0, {duration: CONST.ANIMATION_PAY_BUTTON_DURATION})); + }, [isPaidAnimationRunning, onAnimationFinish, buttonOpacity, buttonScale, height, paymentCompleteTextOpacity, paymentCompleteTextScale, resetAnimation]); + + return ( + + {isPaidAnimationRunning && ( + + Payment complete + + )} + + + + + ); +} + +AnimatedSettlementButton.displayName = 'AnimatedSettlementButton'; + +export default AnimatedSettlementButton; diff --git a/src/components/SettlementButton.tsx b/src/components/SettlementButton/index.tsx similarity index 67% rename from src/components/SettlementButton.tsx rename to src/components/SettlementButton/index.tsx index 2e0a7c927f4..75499b48305 100644 --- a/src/components/SettlementButton.tsx +++ b/src/components/SettlementButton/index.tsx @@ -1,7 +1,11 @@ import React, {useMemo} from 'react'; -import type {GestureResponderEvent, StyleProp, ViewStyle} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; -import {useOnyx, withOnyx} from 'react-native-onyx'; +import type {GestureResponderEvent} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; +import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; +import type {PaymentType} from '@components/ButtonWithDropdownMenu/types'; +import * as Expensicons from '@components/Icon/Expensicons'; +import KYCWall from '@components/KYCWall'; +import {useSession} from '@components/OnyxProvider'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import Navigation from '@libs/Navigation/Navigation'; @@ -12,110 +16,15 @@ import * as BankAccounts from '@userActions/BankAccounts'; import * as IOU from '@userActions/IOU'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Route} from '@src/ROUTES'; import ROUTES from '@src/ROUTES'; -import type {ButtonSizeValue} from '@src/styles/utils/types'; -import type {LastPaymentMethod, Policy, Report} from '@src/types/onyx'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; -import type AnchorAlignment from '@src/types/utils/AnchorAlignment'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import ButtonWithDropdownMenu from './ButtonWithDropdownMenu'; -import type {PaymentType} from './ButtonWithDropdownMenu/types'; -import * as Expensicons from './Icon/Expensicons'; -import KYCWall from './KYCWall'; -import {useSession} from './OnyxProvider'; +import type SettlementButtonProps from './types'; type KYCFlowEvent = GestureResponderEvent | KeyboardEvent | undefined; type TriggerKYCFlow = (event: KYCFlowEvent, iouPaymentType: PaymentMethodType) => void; -type EnablePaymentsRoute = typeof ROUTES.ENABLE_PAYMENTS | typeof ROUTES.IOU_SEND_ENABLE_PAYMENTS | typeof ROUTES.SETTINGS_ENABLE_PAYMENTS; - -type SettlementButtonOnyxProps = { - /** The last payment method used per policy */ - nvpLastPaymentMethod?: OnyxEntry; - - /** The policy of the report */ - policy: OnyxEntry; -}; - -type SettlementButtonProps = SettlementButtonOnyxProps & { - /** Callback to execute when this button is pressed. Receives a single payment type argument. */ - onPress: (paymentType?: PaymentMethodType, payAsBusiness?: boolean) => void; - - /** Callback when the payment options popover is shown */ - onPaymentOptionsShow?: () => void; - - /** Callback when the payment options popover is closed */ - onPaymentOptionsHide?: () => void; - - /** The route to redirect if user does not have a payment method setup */ - enablePaymentsRoute: EnablePaymentsRoute; - - /** Call the onPress function on main button when Enter key is pressed */ - pressOnEnter?: boolean; - - /** Settlement currency type */ - currency?: string; - - /** When the button is opened via an IOU, ID for the chatReport that the IOU is linked to */ - chatReportID?: string; - - /** The IOU/Expense report we are paying */ - iouReport?: OnyxEntry; - - /** Should we show the payment options? */ - shouldHidePaymentOptions?: boolean; - - /** Should we show the payment options? */ - shouldShowApproveButton?: boolean; - - /** Should approve button be disabled? */ - shouldDisableApproveButton?: boolean; - - /** The policyID of the report we are paying */ - policyID?: string; - - /** Additional styles to add to the component */ - style?: StyleProp; - - /** Total money amount in form */ - formattedAmount?: string; - - /** The size of button size */ - buttonSize?: ButtonSizeValue; - - /** Route for the Add Bank Account screen for a given navigation stack */ - addBankAccountRoute?: Route; - - /** Route for the Add Debit Card screen for a given navigation stack */ - addDebitCardRoute?: Route; - - /** Whether the button should be disabled */ - isDisabled?: boolean; - - /** Whether we should show a loading state for the main button */ - isLoading?: boolean; - - /** The anchor alignment of the popover menu for payment method dropdown */ - paymentMethodDropdownAnchorAlignment?: AnchorAlignment; - - /** The anchor alignment of the popover menu for KYC wall popover */ - kycWallAnchorAlignment?: AnchorAlignment; - - /** Whether the personal bank account option should be shown */ - shouldShowPersonalBankAccountOption?: boolean; - - /** The priority to assign the enter key event listener to buttons. 0 is the highest priority. */ - enterKeyEventListenerPriority?: number; - - /** Callback to open confirmation modal if any of the transactions is on HOLD */ - confirmApproval?: () => void; - - /** Whether to use keyboard shortcuts for confirmation or not */ - useKeyboardShortcuts?: boolean; -}; - function SettlementButton({ addDebitCardRoute = ROUTES.IOU_SEND_ADD_DEBIT_CARD, addBankAccountRoute = '', @@ -132,23 +41,20 @@ function SettlementButton({ currency = CONST.CURRENCY.USD, enablePaymentsRoute, iouReport, - // The "nvpLastPaymentMethod" object needs to be stable to prevent the "useMemo" - // hook from being recreated unnecessarily, hence the use of CONST.EMPTY_OBJECT - nvpLastPaymentMethod = CONST.EMPTY_OBJECT, isDisabled = false, isLoading = false, formattedAmount = '', onPress, pressOnEnter = false, - policyID = '', + policyID = '-1', shouldHidePaymentOptions = false, shouldShowApproveButton = false, shouldDisableApproveButton = false, style, + disabledStyle, shouldShowPersonalBankAccountOption = false, enterKeyEventListenerPriority = 0, confirmApproval, - policy, useKeyboardShortcuts = false, onPaymentOptionsShow, onPaymentOptionsHide, @@ -158,6 +64,8 @@ function SettlementButton({ const session = useSession(); // The app would crash due to subscribing to the entire report collection if chatReportID is an empty string. So we should have a fallback ID here. const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID || -1}`); + const [lastPaymentMethod = '-1'] = useOnyx(ONYXKEYS.NVP_LAST_PAYMENT_METHOD, {selector: (paymentMethod) => paymentMethod?.[policyID]}); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); const isInvoiceReport = (!isEmptyObject(iouReport) && ReportUtils.isInvoiceReport(iouReport)) || false; const isPaidGroupPolicy = ReportUtils.isPaidGroupPolicyExpenseChat(chatReport); const shouldShowPaywithExpensifyOption = @@ -198,9 +106,6 @@ function SettlementButton({ } // To achieve the one tap pay experience we need to choose the correct payment type as default. - // If the user has previously chosen a specific payment option or paid for some expense, - // let's use the last payment method or use default. - const paymentMethod = nvpLastPaymentMethod?.[policyID] ?? '-1'; if (canUseWallet) { buttonOptions.push(paymentMethods[CONST.IOU.PAYMENT_TYPE.EXPENSIFY]); } @@ -249,14 +154,27 @@ function SettlementButton({ buttonOptions.push(approveButtonOption); } - // Put the preferred payment method to the front of the array, so it's shown as default - if (paymentMethod) { - return buttonOptions.sort((method) => (method.value === paymentMethod ? -1 : 0)); + // Put the preferred payment method to the front of the array, so it's shown as default. We assume their last payment method is their preferred. + if (lastPaymentMethod) { + return buttonOptions.sort((method) => (method.value === lastPaymentMethod ? -1 : 0)); } return buttonOptions; // We don't want to reorder the options when the preferred payment method changes while the button is still visible // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps - }, [currency, formattedAmount, iouReport, chatReport, policyID, translate, shouldHidePaymentOptions, shouldShowApproveButton, shouldDisableApproveButton]); + }, [ + iouReport, + translate, + formattedAmount, + shouldDisableApproveButton, + isInvoiceReport, + currency, + shouldHidePaymentOptions, + shouldShowApproveButton, + shouldShowPaywithExpensifyOption, + shouldShowPayElsewhereOption, + chatReport, + onPress, + ]); const selectPaymentType = (event: KYCFlowEvent, iouPaymentType: PaymentMethodType, triggerKYCFlow: TriggerKYCFlow) => { if (policy && SubscriptionUtils.shouldRestrictUserBillableActions(policy.id)) { @@ -279,7 +197,7 @@ function SettlementButton({ return; } - playSound(SOUNDS.DONE); + playSound(SOUNDS.SUCCESS); onPress(iouPaymentType); }; @@ -315,8 +233,14 @@ function SettlementButton({ onPress={(event, iouPaymentType) => selectPaymentType(event, iouPaymentType, triggerKYCFlow)} pressOnEnter={pressOnEnter} options={paymentButtonOptions} - onOptionSelected={(option) => savePreferredPaymentMethod(policyID, option.value)} + onOptionSelected={(option) => { + if (policyID === '-1') { + return; + } + savePreferredPaymentMethod(policyID, option.value); + }} style={style} + disabledStyle={disabledStyle} buttonSize={buttonSize} anchorAlignment={paymentMethodDropdownAnchorAlignment} enterKeyEventListenerPriority={enterKeyEventListenerPriority} @@ -329,12 +253,4 @@ function SettlementButton({ SettlementButton.displayName = 'SettlementButton'; -export default withOnyx({ - nvpLastPaymentMethod: { - key: ONYXKEYS.NVP_LAST_PAYMENT_METHOD, - selector: (paymentMethod) => paymentMethod ?? {}, - }, - policy: { - key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - }, -})(SettlementButton); +export default SettlementButton; diff --git a/src/components/SettlementButton/types.ts b/src/components/SettlementButton/types.ts new file mode 100644 index 00000000000..0a26aec914e --- /dev/null +++ b/src/components/SettlementButton/types.ts @@ -0,0 +1,92 @@ +import type {StyleProp, ViewStyle} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import type ROUTES from '@src/ROUTES'; +import type {Route} from '@src/ROUTES'; +import type {ButtonSizeValue} from '@src/styles/utils/types'; +import type {Report} from '@src/types/onyx'; +import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; +import type AnchorAlignment from '@src/types/utils/AnchorAlignment'; + +type EnablePaymentsRoute = typeof ROUTES.ENABLE_PAYMENTS | typeof ROUTES.IOU_SEND_ENABLE_PAYMENTS | typeof ROUTES.SETTINGS_ENABLE_PAYMENTS; + +type SettlementButtonProps = { + /** Callback to execute when this button is pressed. Receives a single payment type argument. */ + onPress: (paymentType?: PaymentMethodType, payAsBusiness?: boolean) => void; + + /** Callback when the payment options popover is shown */ + onPaymentOptionsShow?: () => void; + + /** Callback when the payment options popover is closed */ + onPaymentOptionsHide?: () => void; + + /** The route to redirect if user does not have a payment method setup */ + enablePaymentsRoute: EnablePaymentsRoute; + + /** Call the onPress function on main button when Enter key is pressed */ + pressOnEnter?: boolean; + + /** Settlement currency type */ + currency?: string; + + /** When the button is opened via an IOU, ID for the chatReport that the IOU is linked to */ + chatReportID?: string; + + /** The IOU/Expense report we are paying */ + iouReport?: OnyxEntry; + + /** Should we show the payment options? */ + shouldHidePaymentOptions?: boolean; + + /** Should we show the payment options? */ + shouldShowApproveButton?: boolean; + + /** Should approve button be disabled? */ + shouldDisableApproveButton?: boolean; + + /** The policyID of the report we are paying */ + policyID?: string; + + /** Additional styles to add to the component */ + style?: StyleProp; + + /** Additional styles to add to the component when it's disabled */ + disabledStyle?: StyleProp; + + /** Total money amount in form */ + formattedAmount?: string; + + /** The size of button size */ + buttonSize?: ButtonSizeValue; + + /** Route for the Add Bank Account screen for a given navigation stack */ + addBankAccountRoute?: Route; + + /** Route for the Add Debit Card screen for a given navigation stack */ + addDebitCardRoute?: Route; + + /** Whether the button should be disabled */ + isDisabled?: boolean; + + /** Whether we should show a loading state for the main button */ + isLoading?: boolean; + + /** The anchor alignment of the popover menu for payment method dropdown */ + paymentMethodDropdownAnchorAlignment?: AnchorAlignment; + + /** The anchor alignment of the popover menu for KYC wall popover */ + kycWallAnchorAlignment?: AnchorAlignment; + + /** Whether the personal bank account option should be shown */ + shouldShowPersonalBankAccountOption?: boolean; + + /** The priority to assign the enter key event listener to buttons. 0 is the highest priority. */ + enterKeyEventListenerPriority?: number; + + /** Callback to open confirmation modal if any of the transactions is on HOLD */ + confirmApproval?: () => void; + + /** Whether to use keyboard shortcuts for confirmation or not */ + useKeyboardShortcuts?: boolean; +}; + +export default SettlementButtonProps;