Skip to content

Commit

Permalink
feat(suite-native): select coin modal UI
Browse files Browse the repository at this point in the history
  • Loading branch information
jbazant committed Jan 28, 2025
1 parent 4ee0ed9 commit a1651ea
Show file tree
Hide file tree
Showing 17 changed files with 461 additions and 15 deletions.
10 changes: 10 additions & 0 deletions suite-native/intl/src/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1195,6 +1195,16 @@ export const en = {
description: 'We currently support staking as view-only in Trezor Suite Lite.',
},
},
moduleTrading: {
selectCoin: {
buttonTitle: 'Select coin',
},
networksSheet: {
title: 'Tokens',
popularTitle: 'Popular',
listTitle: 'Tokens',
},
},
};

export type Translations = typeof en;
4 changes: 3 additions & 1 deletion suite-native/module-trading/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@
"@reduxjs/toolkit": "1.9.5",
"@suite-native/navigation": "workspace:*",
"@suite-native/test-utils": "workspace:*",
"expo-linear-gradient": "^14.0.1",
"react": "18.2.0",
"react-native": "0.76.1"
"react-native": "0.76.1",
"react-native-reanimated": "^3.16.7"
}
}
30 changes: 30 additions & 0 deletions suite-native/module-trading/src/components/buy/AmountCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import React from 'react';

import { Card, HStack } from '@suite-native/atoms';

import { SelectAssetButton } from '../general/SelectAssetButton';
import { AssetsSheet } from '../general/AssetsSheet';
import { useAssetsSheetControls } from '../../hooks/useAssetsSheetControls';

export const AmountCard = () => {
const {
showTokensSheet,
isTokensSheetVisible,
hideTokensSheet,
setSelectedAsset,
selectedAsset,
} = useAssetsSheetControls();

return (
<Card>
<HStack>
<SelectAssetButton onPress={showTokensSheet} selectedAsset={selectedAsset} />
</HStack>
<AssetsSheet
isVisible={isTokensSheetVisible}
onClose={hideTokensSheet}
onAssetSelect={setSelectedAsset}
/>
</Card>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { render, fireEvent } from '@suite-native/test-utils';

import { AmountCard } from '../AmountCard';

describe('AmountCard', () => {
it('should display Select coin button', () => {
const { getByText, queryByText } = render(<AmountCard />);

expect(getByText('Select coin')).toBeDefined();
expect(queryByText('Tokens')).toBeNull();
});

it('should display AssetsSheet after button click', () => {
const { getByText } = render(<AmountCard />);

fireEvent.press(getByText('Select coin'));

expect(getByText('Tokens')).toBeDefined();
});

it('should display selected network from AssetsSheet', () => {
const { getByText, queryByText } = render(<AmountCard />);

fireEvent.press(getByText('Select coin'));
fireEvent.press(getByText('BTC'));

expect(queryByText('Tokens')).toBeNull();
expect(getByText('BTC')).toBeDefined();
});
});
60 changes: 60 additions & 0 deletions suite-native/module-trading/src/components/general/AssetButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { ReactNode } from 'react';
import { Pressable, StyleSheet } from 'react-native';
import Animated from 'react-native-reanimated';

import { LinearGradient } from 'expo-linear-gradient';

import { hexToRgba } from '@suite-common/suite-utils';
import { Text } from '@suite-native/atoms';
import { Icon } from '@suite-native/icons';
import { prepareNativeStyle, useNativeStyles } from '@trezor/styles';
import { nativeSpacings } from '@trezor/theme';

export type AssetButtonProps = {
icon: ReactNode;
children: ReactNode;
bgBaseColor: string;
caret?: boolean;
onPress: () => void;
};

const styles = StyleSheet.create({
button: {
height: 36,
padding: nativeSpacings.sp4, // TODO 5?
paddingRight: nativeSpacings.sp12,
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
gap: nativeSpacings.sp8, // TODO 6?
},
});

const gradientBackgroundStyle = prepareNativeStyle(utils => ({
borderRadius: utils.borders.radii.round,
borderWidth: 1,
borderColor: 'rgba(0, 0, 0, 0.06)',
}));

const AnimatedPressable = Animated.createAnimatedComponent(Pressable);

export const AssetButton = ({ bgBaseColor, caret, icon, children, onPress }: AssetButtonProps) => {
const { applyStyle } = useNativeStyles();

return (
<LinearGradient
colors={[hexToRgba(bgBaseColor, 0.3), hexToRgba(bgBaseColor, 0.01)]}
style={applyStyle(gradientBackgroundStyle)}
start={{ x: 0, y: 0.5 }}
end={{ x: 1, y: 0.5 }}
>
<AnimatedPressable onPress={onPress} style={styles.button}>
{icon}
<Text color="textSubdued" variant="callout">
{children}
</Text>
{caret && <Icon name="caretDown" color="textSubdued" size="medium" />}
</AnimatedPressable>
</LinearGradient>
);
};
33 changes: 33 additions & 0 deletions suite-native/module-trading/src/components/general/AssetsSheet.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { NetworkSymbol } from '@suite-common/wallet-config';
import { BottomSheet, VStack } from '@suite-native/atoms';
import { Translation } from '@suite-native/intl';

import { PickerCloseButton } from './PickerCloseButton';
import { PickerHeader } from './PickerHeader';
import { PopularNetworks } from './PopularNetworks';

export type AssetsSheetProps = {
isVisible: boolean;
onClose: () => void;
onAssetSelect: (symbol: NetworkSymbol) => void;
};

export const AssetsSheet = ({ isVisible, onClose, onAssetSelect }: AssetsSheetProps) => {
const onAssetSelectCallback = (symbol: NetworkSymbol) => {
onAssetSelect(symbol);
onClose();
};

return (
<BottomSheet isVisible={isVisible} onClose={onClose} isCloseDisplayed={false}>
<VStack spacing="sp16">
<PickerHeader title={<Translation id="moduleTrading.networksSheet.title" />} />
<PopularNetworks
symbols={['btc', 'eth', 'sol', 'base']}
onNetworkSelect={onAssetSelectCallback}
/>
<PickerCloseButton onPress={onClose} />
</VStack>
</BottomSheet>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { useFormatters } from '@suite-common/formatters';
import { NetworkSymbol } from '@suite-common/wallet-config';
import { CryptoIcon } from '@suite-native/icons';
import { useNativeStyles } from '@trezor/styles';

import { AssetButton } from './AssetButton';

export type AssetButtonProps = {
symbol: NetworkSymbol;
onPress: () => void;
caret?: boolean;
};

export const NetworkButton = ({ symbol, onPress, caret }: AssetButtonProps) => {
const { DisplaySymbolFormatter } = useFormatters();
const { utils } = useNativeStyles();
const baseSymbolColor = utils.coinsColors[symbol];

return (
<AssetButton
bgBaseColor={baseSymbolColor}
caret={caret}
onPress={onPress}
icon={<CryptoIcon symbol={symbol} size="small" />}
>
<DisplaySymbolFormatter value={symbol} areAmountUnitsEnabled={false} />
</AssetButton>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { NetworkSymbol } from '@suite-common/wallet-config';
import { HStack, Text, VStack } from '@suite-native/atoms';
import { Translation } from '@suite-native/intl';

import { NetworkButton } from './NetworkButton';

export type PopularNetworksProps = {
symbols: NetworkSymbol[];
onNetworkSelect: (symbol: NetworkSymbol) => void;
maxNetworkSymbols?: number;
};

const DEFAULT_MAX_NETWORK_SYMBOLS = 4;

export const PopularNetworks = ({
symbols,
onNetworkSelect,
maxNetworkSymbols = DEFAULT_MAX_NETWORK_SYMBOLS,
}: PopularNetworksProps) => {
const limitedSymbols = symbols.slice(0, maxNetworkSymbols);

return (
<VStack>
<Text>
<Translation id="moduleTrading.networksSheet.popularTitle" />
</Text>
<HStack justifyContent="space-between">
{limitedSymbols.map(symbol => (
<NetworkButton
key={symbol}
symbol={symbol}
onPress={() => onNetworkSelect(symbol)}
/>
))}
</HStack>
</VStack>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { NetworkSymbol } from '@suite-common/wallet-config';
import { Button, buttonSchemeToColorsMap } from '@suite-native/atoms';
import { Translation } from '@suite-native/intl';
import { Icon } from '@suite-native/icons';

import { NetworkButton } from './NetworkButton';

export type SelectAssetButtonProps = {
onPress: () => void;
selectedAsset: NetworkSymbol | undefined;
};

export const SelectAssetButton = ({ onPress, selectedAsset }: SelectAssetButtonProps) => {
const { iconColor } = buttonSchemeToColorsMap.primary;

if (selectedAsset) {
return <NetworkButton symbol={selectedAsset} onPress={onPress} caret />;
}

return (
<Button
onPress={onPress}
viewRight={<Icon name="caretDown" color={iconColor} size="medium" />}
size="small"
>
<Translation id="moduleTrading.selectCoin.buttonTitle" />
</Button>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { render, fireEvent } from '@suite-native/test-utils';

import { NetworkButton } from '../NetworkButton';

describe('NetworkButton', () => {
it('should render display name of given symbol', () => {
const { getByText } = render(<NetworkButton symbol="btc" onPress={jest.fn()} />);

expect(getByText('BTC')).toBeDefined();
});

it('should call onPress callback', () => {
const pressSpy = jest.fn();
const { getByText } = render(<NetworkButton symbol="btc" onPress={pressSpy} />);

const button = getByText('BTC');
fireEvent.press(button);

expect(pressSpy).toHaveBeenCalledWith();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { render, fireEvent } from '@suite-native/test-utils';

import { PopularNetworks } from '../PopularNetworks';

describe('PopularNetworks', () => {
it('should render up to 4 networks by default', () => {
const { queryByText } = render(
<PopularNetworks
symbols={['btc', 'eth', 'ada', 'sol', 'doge']}
onNetworkSelect={jest.fn()}
/>,
);

expect(queryByText('BTC')).toBeDefined();
expect(queryByText('ETH')).toBeDefined();
expect(queryByText('ADA')).toBeDefined();
expect(queryByText('SOL')).toBeDefined();
expect(queryByText('DOGE')).toBeNull();
});

it('should render up to maxNetworkSymbols networks', () => {
const { queryByText } = render(
<PopularNetworks
symbols={['btc', 'eth', 'ada', 'sol', 'doge']}
onNetworkSelect={jest.fn()}
maxNetworkSymbols={3}
/>,
);

expect(queryByText('BTC')).toBeDefined();
expect(queryByText('ETH')).toBeDefined();
expect(queryByText('ADA')).toBeDefined();
expect(queryByText('SOL')).toBeNull();
expect(queryByText('DOGE')).toBeNull();
});

it('should call onNetworkSelect callback', () => {
const selectSpy = jest.fn();
const { getByText } = render(
<PopularNetworks symbols={['btc']} onNetworkSelect={selectSpy} />,
);

const button = getByText('BTC');
fireEvent.press(button);

expect(selectSpy).toHaveBeenCalledWith('btc');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { render } from '@suite-native/test-utils';

import { SelectAssetButton } from '../SelectAssetButton';

describe('SelectNetworkButton', () => {
it('should render "select coin" when no network is selected', () => {
const { getByText } = render(
<SelectAssetButton onPress={jest.fn()} selectedAsset={undefined} />,
);

expect(getByText('Select coin')).toBeDefined();
});

it('should render NetworkButton when network is selected', () => {
const { queryByText } = render(
<SelectAssetButton onPress={jest.fn()} selectedAsset="ada" />,
);

expect(queryByText('Select coin')).toBeNull();
expect(queryByText('ADA')).toBeDefined();
});
});
Loading

0 comments on commit a1651ea

Please sign in to comment.