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 all 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
2 changes: 2 additions & 0 deletions src/CONST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions src/components/Button/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,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 @@ -206,6 +209,7 @@ function Button(
enterKeyEventListenerPriority = 0,

style = [],
disabledStyle,
innerStyles = [],
textStyles = [],
textHoverStyles = [],
Expand Down Expand Up @@ -381,6 +385,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 @@ -157,6 +158,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 @@ -68,6 +68,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 [isPaidAnimationRunning, setIsPaidAnimationRunning] = useState(false);
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(() => setIsPaidAnimationRunning(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) {
setIsPaidAnimationRunning(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(
() => isPaidAnimationRunning || IOU.canIOUBePaid(iouReport, chatReport, policy, allTransactions),
[isPaidAnimationRunning, 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
isPaidAnimationRunning={isPaidAnimationRunning}
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 CONST from '@src/CONST';
import SettlementButton from '.';
import type SettlementButtonProps from './types';

type AnimatedSettlementButtonProps = SettlementButtonProps & {
isPaidAnimationRunning: boolean;
onAnimationFinish: () => 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<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 = 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;
}
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: 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 (
<Animated.View style={containerStyles}>
{isPaidAnimationRunning && (
<Animated.View style={paymentCompleteTextStyles}>
<Text style={[styles.buttonMediumText]}>Payment complete</Text>
</Animated.View>
)}
<Animated.View style={buttonStyles}>
<SettlementButton
// eslint-disable-next-line react/jsx-props-no-spreading
{...settlementButtonProps}
isDisabled={isPaidAnimationRunning || isDisabled}
disabledStyle={buttonDisabledStyle}
/>
</Animated.View>
</Animated.View>
);
}

AnimatedSettlementButton.displayName = 'AnimatedSettlementButton';

export default AnimatedSettlementButton;
Loading
Loading