Skip to content

Commit

Permalink
feat(suite-native): send custom fee
Browse files Browse the repository at this point in the history
  • Loading branch information
PeKne committed Nov 15, 2024
1 parent db6b687 commit 2174dd8
Show file tree
Hide file tree
Showing 20 changed files with 856 additions and 104 deletions.
3 changes: 2 additions & 1 deletion packages/theme/src/spacings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,10 @@ export const spacingsPx = (Object.keys(spacings) as Array<Spacing>).reduce((resu
return result;
}, {} as SpacingPx);

type NativeSpacingValue = 2 | 4 | 8 | 12 | 16 | 20 | 24 | 32 | 40 | 52 | 64;
type NativeSpacingValue = 1 | 2 | 4 | 8 | 12 | 16 | 20 | 24 | 32 | 40 | 52 | 64;

export const nativeSpacings = {
sp1: 1,
sp2: 2,
sp4: 4,
sp8: 8,
Expand Down
4 changes: 2 additions & 2 deletions suite-common/wallet-types/src/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ export type ExcludedUtxos = Record<string, 'low-anonymity' | 'dust' | undefined>
export type FeeLevelLabel = FeeLevel['label'];

export const isFinalPrecomposedTransaction = (
tx: GeneralPrecomposedTransaction,
tx?: GeneralPrecomposedTransaction,
): tx is PrecomposedTransactionFinal => {
return tx.type === 'final';
return !!tx && tx.type === 'final';
};
20 changes: 20 additions & 0 deletions suite-native/intl/src/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export const en = {
gotIt: 'Got it',
next: 'Next',
tryAgain: 'Try again',
edit: 'Edit',
},
unknownError: 'Something went wrong',
default: 'Default',
Expand Down Expand Up @@ -983,9 +984,28 @@ export const en = {
normal: 'Normal',
high: 'High',
},
custom: {
addButton: 'Add custom fee',
bottomSheet: {
title: 'Custom fee',
minimumLabel: 'The minimum fee rate is {feePerUnit}',
label: {
feeRate: 'Fee rate',
gasLimit: 'Gas limit',
gasPrice: 'Gas price',
},
total: 'Total fee',
confirmButton: 'Confirm custom fee',
},
card: {
label: 'Custom',
ethereumValues: 'Limit: {gasLimit} • Price: {gasPrice}',
},
},
error: 'You don’t have enough balance to use this fee.',
totalAmount: 'Total amount',
submitButton: 'Review and sign',
total: 'Total fee',
},
review: {
confirmOnDeviceMessage: 'Go to your Trezor and confirm the amounts & recipients.',
Expand Down
64 changes: 64 additions & 0 deletions suite-native/module-send/src/components/CustomFee.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import React, { useState } from 'react';
import Animated, { FadeInLeft, FadeOutLeft } from 'react-native-reanimated';

import { Box, Button } from '@suite-native/atoms';
import { Icon } from '@suite-native/icons';
import { Translation } from '@suite-native/intl';
import { useFormContext } from '@suite-native/forms';

import { CustomFeeBottomSheet } from './CustomFeeBottomSheet';
import { SendFeesFormValues } from '../sendFeesFormSchema';
import { CustomFeeCard } from './CustomFeeCard';
import { NativeSupportedFeeLevel } from '../types';

export const CustomFee = () => {
const [isBottomSheetVisible, setIsBottomSheetVisible] = useState(false);
const [previousSelectedFeeLevelLabel, setPreviousSelectedFeeLevelLabel] =
useState<NativeSupportedFeeLevel>('normal');
const { watch, setValue, getValues } = useFormContext<SendFeesFormValues>();

const isCustomFeeSelected = watch('feeLevel') === 'custom';

const openCustomFeeBottomSheet = () => {
setIsBottomSheetVisible(true);

const currentSelectedFeeLevelLabel = getValues('feeLevel');
if (currentSelectedFeeLevelLabel !== 'custom')
setPreviousSelectedFeeLevelLabel(currentSelectedFeeLevelLabel);
};

const closeCustomFeeBottomSheet = () => {
setIsBottomSheetVisible(false);
};

const cancelCustomFee = () => {
setValue('feeLevel', previousSelectedFeeLevelLabel);
setIsBottomSheetVisible(false);
};

return (
<Box flex={1}>
{isCustomFeeSelected ? (
<CustomFeeCard onEdit={openCustomFeeBottomSheet} onCancel={cancelCustomFee} />
) : (
<Animated.View entering={FadeInLeft.delay(300)} exiting={FadeOutLeft}>
<Box alignSelf="center">
<Button
colorScheme="tertiaryElevation0"
size="small"
viewLeft={<Icon name="plus" size="mediumLarge" />}
onPress={openCustomFeeBottomSheet}
>
<Translation id="moduleSend.fees.custom.addButton" />
</Button>
</Box>
</Animated.View>
)}

<CustomFeeBottomSheet
isVisible={isBottomSheetVisible}
onClose={closeCustomFeeBottomSheet}
/>
</Box>
);
};
114 changes: 114 additions & 0 deletions suite-native/module-send/src/components/CustomFeeBottomSheet.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { useSelector } from 'react-redux';
import Animated, {
FadeInDown,
FadeOutDown,
SlideInDown,
SlideOutDown,
useAnimatedStyle,
withTiming,
} from 'react-native-reanimated';

import { useRoute } from '@react-navigation/native';

import { AlertBox, BottomSheet, Button, HStack, Text, VStack } from '@suite-native/atoms';
import { Translation } from '@suite-native/intl';
import { useFormContext } from '@suite-native/forms';
import { CryptoAmountFormatter, CryptoToFiatAmountFormatter } from '@suite-native/formatters';
import { AccountsRootState, selectAccountNetworkSymbol } from '@suite-common/wallet-core';
import { SendStackParamList, SendStackRoutes, StackProps } from '@suite-native/navigation';

import { SendFeesFormValues } from '../sendFeesFormSchema';
import { CustomFeeInputs } from './CustomFeeInputs';
import { useCustomFee } from '../hooks/useCustomFee';

type CustomFeeBottomSheetProps = {
isVisible: boolean;
onClose: () => void;
};

type RouteProps = StackProps<SendStackParamList, SendStackRoutes.SendAddressReview>['route'];

export const CustomFeeBottomSheet = ({ isVisible, onClose }: CustomFeeBottomSheetProps) => {
const route = useRoute<RouteProps>();
const { accountKey, tokenContract } = route.params;

const { feeValue, isFeeLoading, isSubmittable, isErrorBoxVisible } = useCustomFee({
accountKey,
tokenContract,
});

const networkSymbol = useSelector((state: AccountsRootState) =>
selectAccountNetworkSymbol(state, accountKey),
);

const { setValue, handleSubmit } = useFormContext<SendFeesFormValues>();

const handleSetCustomFee = handleSubmit(() => {
setValue('feeLevel', 'custom');
onClose();
});

const animatedButtonContainerStyle = useAnimatedStyle(
() => ({
height: withTiming(isSubmittable && isVisible ? 50 : 0),
}),
[isSubmittable, isVisible],
);

if (!networkSymbol) return null;

return (
<BottomSheet
isVisible={isVisible}
onClose={onClose}
title={<Translation id="moduleSend.fees.custom.bottomSheet.title" />}
>
<VStack spacing="sp24" justifyContent="space-between" flex={1}>
<CustomFeeInputs networkSymbol={networkSymbol} />
<HStack
flex={1}
justifyContent="space-between"
alignItems="center"
paddingHorizontal="sp1"
>
<Text variant="highlight">
<Translation id="moduleSend.fees.custom.bottomSheet.total" />
</Text>
<VStack alignItems="flex-end">
<CryptoToFiatAmountFormatter
value={feeValue}
isLoading={isFeeLoading}
network={networkSymbol}
/>
<CryptoAmountFormatter
value={feeValue}
network={networkSymbol}
variant="body"
isLoading={isFeeLoading}
isBalance={false}
/>
</VStack>
</HStack>
{isErrorBoxVisible && (
<Animated.View entering={FadeInDown} exiting={FadeOutDown}>
<AlertBox
variant="error"
title={<Translation id="moduleSend.fees.error" />}
contentColor="textDefault"
/>
</Animated.View>
)}

<Animated.View style={animatedButtonContainerStyle}>
{isSubmittable && (
<Animated.View entering={SlideInDown} exiting={SlideOutDown}>
<Button onPress={handleSetCustomFee}>
<Translation id="moduleSend.fees.custom.bottomSheet.confirmButton" />
</Button>
</Animated.View>
)}
</Animated.View>
</VStack>
</BottomSheet>
);
};
123 changes: 123 additions & 0 deletions suite-native/module-send/src/components/CustomFeeCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { useSelector } from 'react-redux';
import Animated, { FadeInLeft, FadeOutLeft } from 'react-native-reanimated';

import { useRoute } from '@react-navigation/native';

import { Card, HStack, VStack, Text, Box, Button } from '@suite-native/atoms';
import { useFormContext } from '@suite-native/forms';
import { Translation } from '@suite-native/intl';
import { CryptoAmountFormatter, CryptoToFiatAmountFormatter } from '@suite-native/formatters';
import { AccountsRootState, selectAccountNetworkSymbol } from '@suite-common/wallet-core';
import { getFeeUnits } from '@suite-common/wallet-utils';
import { prepareNativeStyle, useNativeStyles } from '@trezor/styles';
import { SendStackParamList, SendStackRoutes, StackProps } from '@suite-native/navigation';
import { networks, NetworkType } from '@suite-common/wallet-config';
import { isFinalPrecomposedTransaction } from '@suite-common/wallet-types';

import { SendFeesFormValues } from '../sendFeesFormSchema';
import { selectFeeLevels } from '../sendFormSlice';

type CustomFeeCardProps = {
onEdit: () => void;
onCancel: () => void;
};

const cardStyle = prepareNativeStyle(utils => ({
...utils.boxShadows.none,
}));

type RouteProps = StackProps<SendStackParamList, SendStackRoutes.SendAddressReview>['route'];

const CustomFeeLabel = ({ networkType }: { networkType: NetworkType }) => {
const feeUnits = getFeeUnits(networkType);

const { watch } = useFormContext<SendFeesFormValues>();
const { customFeeLimit, customFeePerUnit } = watch();

const formattedFeePerUnit = `${customFeePerUnit} ${feeUnits}`;

if (networkType === 'ethereum') {
return (
<VStack spacing="sp2" flex={1}>
<Text variant="highlight">
<Translation id="moduleSend.fees.custom.card.label" />
</Text>
<Text variant="hint" color="textSubdued" numberOfLines={1} adjustsFontSizeToFit>
<Translation
id="moduleSend.fees.custom.card.ethereumValues"
values={{ gasPrice: formattedFeePerUnit, gasLimit: customFeeLimit }}
/>
</Text>
</VStack>
);
}

return (
<Text variant="highlight">
<Translation id="moduleSend.fees.custom.card.label" />
{' • '}
<Text color="textSubdued">{formattedFeePerUnit}</Text>
</Text>
);
};

export const CustomFeeCard = ({ onEdit, onCancel }: CustomFeeCardProps) => {
const { applyStyle } = useNativeStyles();
const route = useRoute<RouteProps>();
const { accountKey } = route.params;

const feeLevels = useSelector(selectFeeLevels);

const customFeeTransaction = feeLevels.custom;

const networkSymbol = useSelector((state: AccountsRootState) =>
selectAccountNetworkSymbol(state, accountKey),
);

if (!isFinalPrecomposedTransaction(customFeeTransaction) || !networkSymbol) {
return null;
}

const { networkType } = networks[networkSymbol];

return (
<Animated.View entering={FadeInLeft.delay(300)} exiting={FadeOutLeft}>
<Card style={applyStyle(cardStyle)}>
<VStack spacing="sp16">
<VStack>
<HStack flex={1} justifyContent="space-between" alignItems="center">
<CustomFeeLabel networkType={networkType} />
<VStack alignItems="flex-end" spacing={0}>
<CryptoToFiatAmountFormatter
value={customFeeTransaction.fee}
network={networkSymbol}
variant="body"
/>
<CryptoAmountFormatter
value={customFeeTransaction?.fee}
network={networkSymbol}
isBalance={false}
variant="hint"
numberOfLines={1}
adjustsFontSizeToFit
/>
</VStack>
</HStack>
</VStack>
<HStack flex={1} justifyContent="space-between">
<Box flex={1 / 3}>
<Button onPress={onCancel} colorScheme="redElevation1">
<Translation id="generic.buttons.cancel" />
</Button>
</Box>
<Box flex={2 / 3}>
<Button onPress={onEdit} colorScheme="tertiaryElevation1">
<Translation id="generic.buttons.edit" />
</Button>
</Box>
</HStack>
</VStack>
</Card>
</Animated.View>
);
};
Loading

0 comments on commit 2174dd8

Please sign in to comment.