Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Animate settlement button when pay and trigger a haptic feedback #48615

Merged
merged 18 commits into from
Sep 13, 2024
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/components/Button/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ type ButtonProps = Partial<ChildrenProps> & {
/** Additional styles to add after local styles. Applied to Pressable portion of button */
style?: StyleProp<ViewStyle>;

/** Additional styles to add to the component when it's disabled */
disabledStyle?: StyleProp<ViewStyle>;

/** Additional button styles. Specific to the OpacityView of the button */
innerStyles?: StyleProp<ViewStyle>;

Expand Down Expand Up @@ -202,6 +205,7 @@ function Button(
enterKeyEventListenerPriority = 0,

style = [],
disabledStyle,
innerStyles = [],
textStyles = [],
textHoverStyles = [],
Expand Down Expand Up @@ -376,6 +380,7 @@ function Button(
danger && !isDisabled ? styles.buttonDangerHovered : undefined,
hoverStyles,
]}
disabledStyle={disabledStyle}
id={id}
accessibilityLabel={accessibilityLabel}
role={CONST.ROLE.BUTTON}
Expand Down
2 changes: 2 additions & 0 deletions src/components/ButtonWithDropdownMenu/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ function ButtonWithDropdownMenu<IValueType>({
menuHeaderText = '',
customText,
style,
disabledStyle,
buttonSize = CONST.DROPDOWN_BUTTON_SIZE.MEDIUM,
anchorAlignment = {
horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT,
Expand Down Expand Up @@ -156,6 +157,7 @@ function ButtonWithDropdownMenu<IValueType>({
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)}
Expand Down
3 changes: 3 additions & 0 deletions src/components/ButtonWithDropdownMenu/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ type ButtonWithDropdownMenuProps<TValueType> = {
/** Additional styles to add to the component */
style?: StyleProp<ViewStyle>;

/** Additional styles to add to the component when it's disabled */
disabledStyle?: StyleProp<ViewStyle>;

/** Menu options to display */
/** e.g. [{text: 'Pay with Expensify', icon: Wallet}] */
options: Array<DropdownOption<TValueType>>;
Expand Down
18 changes: 14 additions & 4 deletions src/components/ReportActionItem/ReportPreview.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -136,6 +137,7 @@ function ReportPreview({
[transactions, iouReportID, action],
);

const [shouldStartPaidAnimation, setShouldStartPaidAnimation] = useState(false);
Copy link
Contributor

@ZhenjaHorbach ZhenjaHorbach Sep 9, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes look good !
The only thing I would prefer
Is to pass only ref and use the state inside AnimatedSettlementButton since stopAnimation looks redundant when we pass this function

But it's optional

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think using ref is a good idea since it won't trigger a re-render.

bernhardoj marked this conversation as resolved.
Show resolved Hide resolved
const [isHoldMenuVisible, setIsHoldMenuVisible] = useState(false);
const [requestType, setRequestType] = useState<ActionHandledType>();
const [nonHeldAmount, fullAmount] = ReportUtils.getNonHeldAndFullAmount(iouReport, policy);
Expand Down Expand Up @@ -196,6 +198,7 @@ function ReportPreview({
const {isDelegateAccessRestricted, delegatorEmail} = useDelegateUserDetails();
const [isNoDelegateAccessMenuVisible, setIsNoDelegateAccessMenuVisible] = useState(false);

const stopAnimation = useCallback(() => setShouldStartPaidAnimation(false), []);
const confirmPayment = (type: PaymentMethodType | undefined, payAsBusiness?: boolean) => {
if (!type) {
return;
Expand All @@ -207,6 +210,8 @@ function ReportPreview({
} else if (ReportUtils.hasHeldExpenses(iouReport?.reportID)) {
setIsHoldMenuVisible(true);
} else if (chatReport && iouReport) {
setShouldStartPaidAnimation(true);
HapticFeedback.longPress();
if (ReportUtils.isInvoiceReport(iouReport)) {
IOU.payInvoice(type, chatReport, iouReport, payAsBusiness);
} else {
Expand Down Expand Up @@ -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(
() => shouldStartPaidAnimation || IOU.canIOUBePaid(iouReport, chatReport, policy, allTransactions),
[shouldStartPaidAnimation, iouReport, chatReport, policy, allTransactions],
);

const shouldShowApproveButton = useMemo(() => IOU.canApproveIOU(iouReport, policy), [iouReport, policy]);

Expand Down Expand Up @@ -473,7 +481,9 @@ function ReportPreview({
</View>
</View>
{shouldShowSettlementButton && (
<SettlementButton
<AnimatedSettlementButton
shouldStartPaidAnimation={shouldStartPaidAnimation}
onAnimationFinish={stopAnimation}
formattedAmount={getSettlementAmount() ?? ''}
currency={iouReport?.currency}
policyID={policyID}
Expand Down
93 changes: 93 additions & 0 deletions src/components/SettlementButton/AnimatedSettlementButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import React, {useCallback, useEffect} from 'react';
import Animated, {runOnJS, useAnimatedStyle, useSharedValue, withDelay, withTiming} from 'react-native-reanimated';
import Text from '@components/Text';
import useThemeStyles from '@hooks/useThemeStyles';
import variables from '@styles/variables';
import SettlementButton from '.';
import type SettlementButtonProps from './types';

type AnimatedSettlementButtonProps = SettlementButtonProps & {
shouldStartPaidAnimation: boolean;
onAnimationFinish: () => void;
};

function AnimatedSettlementButton({shouldStartPaidAnimation, 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<number>(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 = shouldStartPaidAnimation
? {
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 (!shouldStartPaidAnimation) {
resetAnimation();
return;
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reset the animation here in case of a pay error.

error.mp4

// eslint-disable-next-line react-compiler/react-compiler
buttonScale.value = withTiming(0, {duration: 200});
Copy link
Contributor

@ZhenjaHorbach ZhenjaHorbach Sep 9, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's create constants for 200 and 1200
So as not to change many values ​​in case we want to change the animation

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

buttonOpacity.value = withTiming(0, {duration: 200});
paymentCompleteTextScale.value = withTiming(1, {duration: 200});

// Wait for the above animation + 1s delay before hiding the component
height.value = withDelay(
1200,
withTiming(0, {duration: 200}, () => runOnJS(onAnimationFinish)()),
);
paymentCompleteTextOpacity.value = withDelay(1200, withTiming(0, {duration: 200}));
}, [shouldStartPaidAnimation, onAnimationFinish, buttonOpacity, buttonScale, height, paymentCompleteTextOpacity, paymentCompleteTextScale, resetAnimation]);

return (
<Animated.View style={containerStyles}>
{shouldStartPaidAnimation && (
<Animated.View style={paymentCompleteTextStyles}>
<Text style={[styles.buttonMediumText]}>Payment complete</Text>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should've translated this

</Animated.View>
)}
<Animated.View style={buttonStyles}>
<SettlementButton
// eslint-disable-next-line react/jsx-props-no-spreading
{...settlementButtonProps}
isDisabled={shouldStartPaidAnimation || isDisabled}
disabledStyle={buttonDisabledStyle}
/>
</Animated.View>
</Animated.View>
);
}

AnimatedSettlementButton.displayName = 'AnimatedSettlementButton';

export default AnimatedSettlementButton;
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import React, {useMemo} from 'react';
import type {GestureResponderEvent, StyleProp, ViewStyle} from 'react-native';
import type {GestureResponderEvent} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
import {useOnyx, withOnyx} 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';
Expand All @@ -14,25 +19,16 @@ import * as IOU from '@userActions/IOU';
import * as PolicyActions from '@userActions/Policy/Policy';
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 {LastPaymentMethod, Policy} 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<LastPaymentMethod>;
Expand All @@ -41,82 +37,7 @@ type SettlementButtonOnyxProps = {
policy: OnyxEntry<Policy>;
};

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<Report>;

/** 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<ViewStyle>;

/** Total money amount in form <currency><amount> */
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;
};
type SettlementButtonWithOnyxProps = SettlementButtonProps & SettlementButtonOnyxProps;

function SettlementButton({
addDebitCardRoute = ROUTES.IOU_SEND_ADD_DEBIT_CARD,
Expand Down Expand Up @@ -147,14 +68,15 @@ function SettlementButton({
shouldShowApproveButton = false,
shouldDisableApproveButton = false,
style,
disabledStyle,
shouldShowPersonalBankAccountOption = false,
enterKeyEventListenerPriority = 0,
confirmApproval,
policy,
useKeyboardShortcuts = false,
onPaymentOptionsShow,
onPaymentOptionsHide,
}: SettlementButtonProps) {
}: SettlementButtonWithOnyxProps) {
const {translate} = useLocalize();
const {isOffline} = useNetwork();
const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID);
Expand Down Expand Up @@ -286,7 +208,7 @@ function SettlementButton({
return;
}

playSound(SOUNDS.DONE);
playSound(SOUNDS.SUCCESS);
onPress(iouPaymentType);
};

Expand Down Expand Up @@ -324,6 +246,7 @@ function SettlementButton({
options={paymentButtonOptions}
onOptionSelected={(option) => savePreferredPaymentMethod(policyID, option.value)}
style={style}
disabledStyle={disabledStyle}
buttonSize={buttonSize}
anchorAlignment={paymentMethodDropdownAnchorAlignment}
enterKeyEventListenerPriority={enterKeyEventListenerPriority}
Expand All @@ -336,7 +259,7 @@ function SettlementButton({

SettlementButton.displayName = 'SettlementButton';

export default withOnyx<SettlementButtonProps, SettlementButtonOnyxProps>({
export default withOnyx<SettlementButtonWithOnyxProps, SettlementButtonOnyxProps>({
bernhardoj marked this conversation as resolved.
Show resolved Hide resolved
nvpLastPaymentMethod: {
key: ONYXKEYS.NVP_LAST_PAYMENT_METHOD,
selector: (paymentMethod) => paymentMethod ?? {},
Expand Down
Loading
Loading