Skip to content

Commit

Permalink
feat(suite-native): fee options animated design
Browse files Browse the repository at this point in the history
  • Loading branch information
PeKne committed Sep 20, 2024
1 parent d92ccc1 commit 7c09850
Show file tree
Hide file tree
Showing 9 changed files with 327 additions and 143 deletions.
17 changes: 16 additions & 1 deletion suite-common/wallet-core/src/fees/feesReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export const selectNetworkFeeLevelTimeEstimate = (
state: FeesRootState,
level: FeeLevelLabel,
networkSymbol?: NetworkSymbol,
) => {
): string | null => {
const networkFeeInfo = selectNetworkFeeInfo(state, networkSymbol);
if (!networkFeeInfo) return null;

Expand All @@ -56,3 +56,18 @@ export const selectNetworkFeeLevelTimeEstimate = (

return formatDuration(networkFeeInfo.blockTime * feeLevel.blocks * 60);
};

export const selectNetworkFeeLevelFeePerUnit = (
state: FeesRootState,
level: FeeLevelLabel,
networkSymbol?: NetworkSymbol,
): string | null => {
const networkFeeInfo = selectNetworkFeeInfo(state, networkSymbol);
if (!networkFeeInfo || !networkSymbol) return null;

const feeLevel = networkFeeInfo.levels.find(x => x.label === level);

if (!feeLevel) return null;

return feeLevel.feePerUnit;
};
52 changes: 29 additions & 23 deletions suite-native/atoms/src/Radio.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { TouchableOpacity, TouchableOpacityProps, View } from 'react-native';

import { NativeStyleObject, prepareNativeStyle, useNativeStyles } from '@trezor/styles';
import { Color } from '@trezor/theme';

import { ACCESSIBILITY_FONTSIZE_MULTIPLIER } from './Text';

Expand All @@ -10,51 +11,56 @@ export type RadioProps<TValue> = Omit<TouchableOpacityProps, 'style' | 'onPress'
isDisabled?: boolean;
onPress: (value: TValue) => void;
style?: NativeStyleObject;
activeColor?: Color;
};

type RadioStyleProps = {
isChecked: boolean;
isDisabled: boolean;
activeColor: Color;
};

const RADIO_SIZE = 24 * ACCESSIBILITY_FONTSIZE_MULTIPLIER;
const RADIO_CHECK_SIZE = 14 * ACCESSIBILITY_FONTSIZE_MULTIPLIER;

const radioStyle = prepareNativeStyle<RadioStyleProps>((utils, { isChecked, isDisabled }) => ({
height: RADIO_SIZE,
width: RADIO_SIZE,
backgroundColor: isDisabled
? utils.colors.backgroundNeutralDisabled
: utils.colors.backgroundSurfaceElevation1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderRadius: utils.borders.radii.round,
borderWidth: isChecked ? utils.borders.widths.large : utils.borders.widths.medium,
borderColor: utils.colors.borderElevation2,
extend: {
condition: isChecked && !isDisabled,
style: { borderColor: utils.colors.borderSecondary },
},
}));
const radioStyle = prepareNativeStyle<RadioStyleProps>(
(utils, { isChecked, isDisabled, activeColor }) => ({
height: RADIO_SIZE,
width: RADIO_SIZE,
backgroundColor: isDisabled
? utils.colors.backgroundNeutralDisabled
: utils.colors.backgroundSurfaceElevation1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderRadius: utils.borders.radii.round,
borderWidth: isChecked ? utils.borders.widths.large : utils.borders.widths.medium,
borderColor: utils.colors.borderElevation2,
extend: {
condition: isChecked && !isDisabled,
style: { borderColor: utils.colors[activeColor] },
},
}),
);

const radioCheckStyle = prepareNativeStyle<Omit<RadioStyleProps, 'isChecked'>>(
(utils, { isDisabled }) => ({
(utils, { isDisabled, activeColor }) => ({
height: RADIO_CHECK_SIZE,
width: RADIO_CHECK_SIZE,
borderRadius: utils.borders.radii.round,
backgroundColor: isDisabled
? utils.colors.backgroundNeutralDisabled
: utils.colors.backgroundPrimaryDefault,
: utils.colors[activeColor],
}),
);

export const Radio = <TValue extends string | number>({
value,
isChecked = false,
onPress,
isDisabled = false,
style,
isChecked = false,
isDisabled = false,
activeColor = 'backgroundPrimaryDefault',
...props
}: RadioProps<TValue>) => {
const { applyStyle } = useNativeStyles();
Expand All @@ -63,10 +69,10 @@ export const Radio = <TValue extends string | number>({
<TouchableOpacity
disabled={isDisabled}
onPress={() => onPress(value)}
style={[applyStyle(radioStyle, { isChecked, isDisabled }), style]}
style={[applyStyle(radioStyle, { isChecked, isDisabled, activeColor }), style]}
{...props}
>
{isChecked && <View style={applyStyle(radioCheckStyle, { isDisabled })} />}
{isChecked && <View style={applyStyle(radioCheckStyle, { isDisabled, activeColor })} />}
</TouchableOpacity>
);
};
16 changes: 4 additions & 12 deletions suite-native/intl/src/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -869,19 +869,11 @@ export const en = {
body: 'Fees are paid directly to network miners for processing your transactions.',
},
levels: {
low: {
label: 'Low',
timeEstimate: '~ 1 hour',
},
medium: {
label: 'Medium',
timeEstimate: '~ 20 minutes',
},
high: {
label: 'High',
timeEstimate: '~ 10 minutes',
},
low: 'Low',
medium: 'Medium',
high: 'High',
},
error: 'You don’t have enough balance to use this fee.',
totalAmount: 'Total amount',
submitButton: 'Review and sign',
},
Expand Down
183 changes: 130 additions & 53 deletions suite-native/module-send/src/components/FeeOption.tsx
Original file line number Diff line number Diff line change
@@ -1,92 +1,169 @@
import { useContext } from 'react';
import { TouchableOpacity } from 'react-native';
import { Pressable } from 'react-native';
import { useSelector } from 'react-redux';
import Animated, {
interpolateColor,
useAnimatedStyle,
useDerivedValue,
withTiming,
} from 'react-native-reanimated';

import { NetworkSymbol } from '@suite-common/wallet-config';
import { getNetworkType, NetworkSymbol } from '@suite-common/wallet-config';
import { GeneralPrecomposedTransactionFinal } from '@suite-common/wallet-types';
import { Text, HStack, VStack, Radio } from '@suite-native/atoms';
import { Text, HStack, VStack, Radio, Box } from '@suite-native/atoms';
import { CryptoToFiatAmountFormatter, CryptoAmountFormatter } from '@suite-native/formatters';
import { FormContext } from '@suite-native/forms';
import { TxKeyPath, Translation } from '@suite-native/intl';
import { FeesRootState, selectNetworkFeeLevelTimeEstimate } from '@suite-common/wallet-core';
import {
FeesRootState,
selectNetworkFeeLevelFeePerUnit,
selectNetworkFeeLevelTimeEstimate,
} from '@suite-common/wallet-core';
import { Color } from '@trezor/theme';
import { prepareNativeStyle, useNativeStyles } from '@trezor/styles';
import { getFeeUnits } from '@suite-common/wallet-utils';

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

const feeLabelsMap = {
economy: {
label: 'moduleSend.fees.levels.low.label',
timeEstimate: 'moduleSend.fees.levels.low.timeEstimate',
},
normal: {
label: 'moduleSend.fees.levels.medium.label',
timeEstimate: 'moduleSend.fees.levels.medium.timeEstimate',
},
high: {
label: 'moduleSend.fees.levels.high.label',
timeEstimate: 'moduleSend.fees.levels.high.timeEstimate',
},
} as const satisfies Record<NativeSupportedFeeLevel, { label: TxKeyPath; timeEstimate: TxKeyPath }>;
economy: 'moduleSend.fees.levels.low',
normal: 'moduleSend.fees.levels.medium',
high: 'moduleSend.fees.levels.high',
} as const satisfies Record<NativeSupportedFeeLevel, TxKeyPath>;

const wrapperStyle = prepareNativeStyle(utils => ({
overflow: 'hidden',
borderRadius: utils.borders.radii.medium,
borderWidth: utils.borders.widths.large,
backgroundColor: utils.colors.backgroundSurfaceElevation1,
borderColor: utils.colors.backgroundSurfaceElevation0,
}));

const valuesWrapperStyle = prepareNativeStyle(utils => ({
padding: utils.spacings.medium,
}));

export const FeeOption = ({
feeKey,
feeLevel,
networkSymbol,
transactionBytes,
}: {
feeKey: SendFeesFormValues['feeLevel'];
feeLevel: GeneralPrecomposedTransactionFinal;
networkSymbol: NetworkSymbol;
transactionBytes: number;
}) => {
const { utils } = useNativeStyles();
const { applyStyle } = useNativeStyles();
const { watch, setValue } = useContext(FormContext);
const selectedLevel = watch('feeLevel');

const feeTimeEstimate = useSelector((state: FeesRootState) =>
selectNetworkFeeLevelTimeEstimate(state, feeKey, networkSymbol),
);

const isChecked = selectedLevel === feeKey;
const feePerUnit = useSelector((state: FeesRootState) =>
selectNetworkFeeLevelFeePerUnit(state, feeKey, networkSymbol),
);

const { label } = feeLabelsMap[feeKey];
const isErrorFee = feeLevel.type !== 'final';

const handleSelectFeeLevel = () => {
setValue('feeLevel', feeKey, {
shouldValidate: true,
});
};

const selectedLevel = watch('feeLevel');
const isChecked = selectedLevel === feeKey;

const highlightColor: Color = isErrorFee
? 'backgroundAlertRedBold'
: 'backgroundSecondaryDefault';

const borderAnimationValue = useDerivedValue(
() => (isChecked ? withTiming(1) : withTiming(0)),
[isChecked],
);

const animatedCardStyle = useAnimatedStyle(
() => ({
borderColor: interpolateColor(
borderAnimationValue.value,
[0, 1],
[utils.colors.backgroundSurfaceElevation0, utils.colors[highlightColor]],
),
}),
[borderAnimationValue, highlightColor],
);

const label = feeLabelsMap[feeKey];
const networkType = getNetworkType(networkSymbol);
const feeUnits = getFeeUnits(networkType);
const formattedFeePerUnit = `${feePerUnit} ${feeUnits}`;

// If trezor-connect was not able to compose the fee level, we have to mock its value.
const mockedFee = transactionBytes * Number(feePerUnit);
const fee = isErrorFee ? mockedFee.toString() : feeLevel.fee;

return (
<TouchableOpacity onPress={handleSelectFeeLevel}>
<HStack spacing="large" justifyContent="space-between" flex={1} alignItems="center">
<VStack alignItems="flex-start" spacing="extraSmall">
<Text variant="highlight">
<Translation id={label} />
</Text>
<Text variant="hint" color="textSubdued">
{feeTimeEstimate}
</Text>
</VStack>
<VStack flex={1} alignItems="flex-end" spacing="extraSmall">
<CryptoToFiatAmountFormatter
variant="body"
color="textDefault"
value={feeLevel.fee}
network={networkSymbol}
/>
<CryptoAmountFormatter
variant="hint"
color="textSubdued"
value={feeLevel.fee}
network={networkSymbol}
isBalance={false}
/>
</VStack>
<Radio
isChecked={isChecked}
value={feeKey}
onPress={handleSelectFeeLevel}
testID={`@send/fees-level-${feeKey}`}
/>
</HStack>
</TouchableOpacity>
<Pressable onPress={handleSelectFeeLevel}>
<Animated.View
style={[
applyStyle(wrapperStyle, { borderColor: highlightColor }),
animatedCardStyle,
]}
>
<Box style={applyStyle(valuesWrapperStyle)}>
<HStack
spacing="large"
justifyContent="space-between"
flex={1}
alignItems="center"
>
<VStack alignItems="flex-start" spacing="extraSmall">
<Box alignItems="center" flexDirection="row">
<Text variant="highlight">
<Translation id={label} />
{' • '}
</Text>
<Text variant="hint" color="textSubdued">
{formattedFeePerUnit}
</Text>
</Box>
<Text variant="hint" color="textSubdued">
{`~ ${feeTimeEstimate}`}
</Text>
</VStack>
<VStack flex={1} alignItems="flex-end" spacing="extraSmall">
<CryptoToFiatAmountFormatter
variant="body"
color="textDefault"
value={fee}
network={networkSymbol}
/>
<CryptoAmountFormatter
variant="hint"
color="textSubdued"
value={fee}
network={networkSymbol}
isBalance={false}
/>
</VStack>
<Radio
isChecked={isChecked}
value={feeKey}
activeColor={isErrorFee ? 'iconAlertRed' : 'backgroundPrimaryDefault'}
onPress={handleSelectFeeLevel}
testID={`@send/fees-level-${feeKey}`}
/>
</HStack>
</Box>

{isErrorFee && <FeeOptionErrorMessage isVisible={isChecked} />}
</Animated.View>
</Pressable>
);
};
Loading

0 comments on commit 7c09850

Please sign in to comment.