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 the paid checkmark when paying expense #49438

Merged
merged 11 commits into from
Sep 20, 2024
5 changes: 3 additions & 2 deletions src/CONST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,8 +189,9 @@ 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,
ANIMATION_PAID_DURATION: 200,
ANIMATION_PAID_CHECKMARK_DELAY: 300,
ANIMATION_PAID_BUTTON_HIDE_DELAY: 1000,
BACKGROUND_IMAGE_TRANSITION_DURATION: 1000,
SCREEN_TRANSITION_END_TIMEOUT: 1000,
ARROW_HIDE_DELAY: 3000,
Expand Down
134 changes: 74 additions & 60 deletions src/components/ReportActionItem/ReportPreview.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import truncate from 'lodash/truncate';
import React, {useCallback, useMemo, useState} from 'react';
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import type {StyleProp, ViewStyle} from 'react-native';
import {View} from 'react-native';
import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
import {useOnyx, withOnyx} from 'react-native-onyx';
import {useOnyx} from 'react-native-onyx';
import Animated, {useAnimatedStyle, useSharedValue, withDelay, withSpring, withTiming} from 'react-native-reanimated';
import Button from '@components/Button';
import DelegateNoAccessModal from '@components/DelegateNoAccessModal';
import Icon from '@components/Icon';
Expand All @@ -18,6 +18,7 @@ import Text from '@components/Text';
import useDelegateUserDetails from '@hooks/useDelegateUserDetails';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import usePolicy from '@hooks/usePolicy';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import ControlSelection from '@libs/ControlSelection';
Expand All @@ -39,33 +40,13 @@ import CONST from '@src/CONST';
import type {TranslationPaths} from '@src/languages/types';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type {Policy, Report, ReportAction, Transaction, TransactionViolations, UserWallet} from '@src/types/onyx';
import type {ReportAction} from '@src/types/onyx';
import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage';
import ExportWithDropdownMenu from './ExportWithDropdownMenu';
import type {PendingMessageProps} from './MoneyRequestPreview/types';
import ReportActionItemImages from './ReportActionItemImages';

type ReportPreviewOnyxProps = {
/** The policy tied to the expense report */
policy: OnyxEntry<Policy>;

/** ChatReport associated with iouReport */
chatReport: OnyxEntry<Report>;

/** Active IOU Report for current report */
iouReport: OnyxEntry<Report>;

/** All the transactions, used to update ReportPreview label and status */
transactions: OnyxCollection<Transaction>;

/** All of the transaction violations */
transactionViolations: OnyxCollection<TransactionViolations>;

/** The user's wallet account */
userWallet: OnyxEntry<UserWallet>;
};

type ReportPreviewProps = ReportPreviewOnyxProps & {
type ReportPreviewProps = {
/** All the data of the action */
action: ReportAction;

Expand Down Expand Up @@ -101,24 +82,24 @@ type ReportPreviewProps = ReportPreviewOnyxProps & {
};

function ReportPreview({
iouReport,
policy,
iouReportID,
policyID,
chatReportID,
chatReport,
action,
containerStyles,
contextMenuAnchor,
transactions,
transactionViolations,
isHovered = false,
isWhisper = false,
checkIfContextMenuActive = () => {},
onPaymentOptionsShow,
onPaymentOptionsHide,
userWallet,
}: ReportPreviewProps) {
const policy = usePolicy(policyID);
const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`);
const [iouReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`);
const [transactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION);
const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS);
const [userWallet] = useOnyx(ONYXKEYS.USER_WALLET);
const theme = useTheme();
const styles = useThemeStyles();
const {translate} = useLocalize();
Expand Down Expand Up @@ -151,6 +132,18 @@ function ReportPreview({
const {totalDisplaySpend, reimbursableSpend} = ReportUtils.getMoneyRequestSpendBreakdown(iouReport);

const iouSettled = ReportUtils.isSettled(iouReportID) || action?.childStatusNum === CONST.REPORT.STATUS_NUM.REIMBURSED;
const previewMessageOpacity = useSharedValue(1);
const previewMessageStyle = useAnimatedStyle(() => ({
...styles.flex1,
...styles.flexRow,
...styles.alignItemsCenter,
opacity: previewMessageOpacity.value,
}));
const checkMarkScale = useSharedValue(iouSettled ? 1 : 0);
const checkMarkStyle = useAnimatedStyle(() => ({
...styles.defaultCheckmarkWrapper,
transform: [{scale: checkMarkScale.value}],
}));

const moneyRequestComment = action?.childLastMoneyRequestComment ?? '';
const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(chatReport);
Expand Down Expand Up @@ -285,14 +278,14 @@ function ReportPreview({
return !Number.isNaN(amount) && amount === 0;
}

const getPreviewMessage = () => {
const previewMessage = useMemo(() => {
if (isScanning) {
return translate('common.receipt');
}

let payerOrApproverName;
if (isPolicyExpenseChat) {
payerOrApproverName = ReportUtils.getPolicyName(chatReport);
payerOrApproverName = ReportUtils.getPolicyName(chatReport, undefined, policy);
} else if (isInvoiceRoom) {
payerOrApproverName = ReportUtils.getInvoicePayerName(chatReport, invoiceReceiverPolicy);
} else {
Expand All @@ -310,7 +303,20 @@ function ReportPreview({
payerOrApproverName = ReportUtils.getDisplayNameForParticipant(chatReport?.ownerAccountID, true);
}
return translate(paymentVerb, {payer: payerOrApproverName});
};
}, [
isScanning,
isPolicyExpenseChat,
policy,
chatReport,
isInvoiceRoom,
invoiceReceiverPolicy,
managerID,
isApproved,
iouSettled,
iouReport?.isWaitingOnBankAccount,
hasNonReimbursableTransactions,
translate,
]);

const bankAccountRoute = ReportUtils.getBankAccountRoute(chatReport);

Expand Down Expand Up @@ -400,6 +406,33 @@ function ReportPreview({
const isAdmin = policy?.role === CONST.POLICY.ROLE.ADMIN;
const shouldShowExportIntegrationButton = !shouldShowPayButton && !shouldShowSubmitButton && connectedIntegration && isAdmin && ReportUtils.canBeExported(iouReport);

useEffect(() => {
if (!isPaidAnimationRunning) {
return;
}

// eslint-disable-next-line react-compiler/react-compiler
previewMessageOpacity.value = withTiming(0.75, {duration: CONST.ANIMATION_PAID_DURATION / 2}, () => {
// eslint-disable-next-line react-compiler/react-compiler
previewMessageOpacity.value = withTiming(1, {duration: CONST.ANIMATION_PAID_DURATION / 2});
});
// We only want to animate the text when the text changes
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
}, [previewMessage, previewMessageOpacity]);

useEffect(() => {
if (!iouSettled) {
return;
}

if (isPaidAnimationRunning) {
// eslint-disable-next-line react-compiler/react-compiler
checkMarkScale.value = withDelay(CONST.ANIMATION_PAID_CHECKMARK_DELAY, withSpring(1, {duration: CONST.ANIMATION_PAID_DURATION}));
} else {
checkMarkScale.value = 1;
}
}, [isPaidAnimationRunning, iouSettled, checkMarkScale]);

return (
<OfflineWithFeedback
pendingAction={iouReport?.pendingFields?.preview}
Expand Down Expand Up @@ -429,12 +462,12 @@ function ReportPreview({
/>
)}
<View style={[styles.expenseAndReportPreviewBoxBody, hasReceipts ? styles.mtn1 : {}]}>
<View style={styles.expenseAndReportPreviewTextButtonContainer}>
<View style={shouldShowSettlementButton ? {} : styles.expenseAndReportPreviewTextButtonContainer}>
<View style={styles.expenseAndReportPreviewTextContainer}>
<View style={styles.flexRow}>
<View style={[styles.flex1, styles.flexRow, styles.alignItemsCenter]}>
<Text style={[styles.textLabelSupporting, styles.lh16]}>{getPreviewMessage()}</Text>
</View>
<Animated.View style={previewMessageStyle}>
<Text style={[styles.textLabelSupporting, styles.lh16]}>{previewMessage}</Text>
</Animated.View>
{shouldShowRBR && (
<Icon
src={Expensicons.DotIndicator}
Expand All @@ -454,12 +487,12 @@ function ReportPreview({
<View style={[styles.flex1, styles.flexRow, styles.alignItemsCenter]}>
<Text style={styles.textHeadlineH1}>{getDisplayAmount()}</Text>
{iouSettled && (
<View style={styles.defaultCheckmarkWrapper}>
<Animated.View style={checkMarkStyle}>
<Icon
src={Expensicons.Checkmark}
fill={theme.iconSuccessFill}
/>
</View>
</Animated.View>
)}
</View>
</View>
Expand Down Expand Up @@ -562,23 +595,4 @@ function ReportPreview({

ReportPreview.displayName = 'ReportPreview';

export default withOnyx<ReportPreviewProps, ReportPreviewOnyxProps>({
policy: {
key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
},
chatReport: {
key: ({chatReportID}) => `${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`,
},
iouReport: {
key: ({iouReportID}) => `${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`,
},
transactions: {
key: ONYXKEYS.COLLECTION.TRANSACTION,
},
transactionViolations: {
key: ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS,
},
userWallet: {
key: ONYXKEYS.USER_WALLET,
},
})(ReportPreview);
export default ReportPreview;
20 changes: 12 additions & 8 deletions src/components/SettlementButton/AnimatedSettlementButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ function AnimatedSettlementButton({isPaidAnimationRunning, onAnimationFinish, is
const paymentCompleteTextScale = useSharedValue(0);
const paymentCompleteTextOpacity = useSharedValue(1);
const height = useSharedValue<number>(variables.componentSizeNormal);
const buttonMarginTop = useSharedValue<number>(styles.expenseAndReportPreviewTextButtonContainer.gap);
const buttonStyles = useAnimatedStyle(() => ({
transform: [{scale: buttonScale.value}],
opacity: buttonOpacity.value,
Expand All @@ -33,6 +34,7 @@ function AnimatedSettlementButton({isPaidAnimationRunning, onAnimationFinish, is
height: height.value,
justifyContent: 'center',
overflow: 'hidden',
marginTop: buttonMarginTop.value,
}));
const buttonDisabledStyle = isPaidAnimationRunning
? {
Expand All @@ -48,26 +50,28 @@ function AnimatedSettlementButton({isPaidAnimationRunning, onAnimationFinish, is
paymentCompleteTextScale.value = 0;
paymentCompleteTextOpacity.value = 1;
height.value = variables.componentSizeNormal;
}, [buttonScale, buttonOpacity, paymentCompleteTextScale, paymentCompleteTextOpacity, height]);
buttonMarginTop.value = styles.expenseAndReportPreviewTextButtonContainer.gap;
}, [buttonScale, buttonOpacity, paymentCompleteTextScale, paymentCompleteTextOpacity, height, buttonMarginTop, styles.expenseAndReportPreviewTextButtonContainer.gap]);

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});
buttonScale.value = withTiming(0, {duration: CONST.ANIMATION_PAID_DURATION});
buttonOpacity.value = withTiming(0, {duration: CONST.ANIMATION_PAID_DURATION});
paymentCompleteTextScale.value = withTiming(1, {duration: CONST.ANIMATION_PAID_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;
const totalDelay = CONST.ANIMATION_PAID_DURATION + CONST.ANIMATION_PAID_BUTTON_HIDE_DELAY;
height.value = withDelay(
totalDelay,
withTiming(0, {duration: CONST.ANIMATION_PAY_BUTTON_DURATION}, () => runOnJS(onAnimationFinish)()),
withTiming(0, {duration: CONST.ANIMATION_PAID_DURATION}, () => runOnJS(onAnimationFinish)()),
Copy link
Contributor

@roryabraham roryabraham Sep 19, 2024

Choose a reason for hiding this comment

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

Small thing I'm noticing is that we're just using withTiming, but we could use withSpring and/or withBounce to give these animations a more natural feel.

Copy link
Contributor

Choose a reason for hiding this comment

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

we talked in slack about using bounce for showing the checkmark

);
paymentCompleteTextOpacity.value = withDelay(totalDelay, withTiming(0, {duration: CONST.ANIMATION_PAY_BUTTON_DURATION}));
}, [isPaidAnimationRunning, onAnimationFinish, buttonOpacity, buttonScale, height, paymentCompleteTextOpacity, paymentCompleteTextScale, resetAnimation]);
buttonMarginTop.value = withDelay(totalDelay, withTiming(0, {duration: CONST.ANIMATION_PAID_DURATION}));
paymentCompleteTextOpacity.value = withDelay(totalDelay, withTiming(0, {duration: CONST.ANIMATION_PAID_DURATION}));
}, [isPaidAnimationRunning, onAnimationFinish, buttonOpacity, buttonScale, height, paymentCompleteTextOpacity, paymentCompleteTextScale, buttonMarginTop, resetAnimation]);

return (
<Animated.View style={containerStyles}>
Expand Down
Loading