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 0a3e0cc commit c16db60
Show file tree
Hide file tree
Showing 16 changed files with 421 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 { SelectNetworkButton } from '../general/SelectNetworkButton';
import { NetworksSheet } from '../general/NetworksSheet';
import { useTokensSheetControls } from '../../hooks/useTokensSheetControls';

export const AmountCard = () => {
const {
showTokensSheet,
isTokensSheetVisible,
hideTokensSheet,
setSelectedNetwork,
selectedNetwork,
} = useTokensSheetControls();

return (
<Card>
<HStack>
<SelectNetworkButton onPress={showTokensSheet} selectedNetwork={selectedNetwork} />
</HStack>
<NetworksSheet
isVisible={isTokensSheetVisible}
onClose={hideTokensSheet}
onNetworkSelect={setSelectedNetwork}
/>
</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 NetworksSheet after button click', () => {
const { getByText } = render(<AmountCard />);

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

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

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

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

expect(queryByText('Tokens')).toBeNull();
expect(getByText('BTC')).toBeDefined();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
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 { useFormatters } from '@suite-common/formatters';
import { NetworkSymbol } from '@suite-common/wallet-config';
import { Text } from '@suite-native/atoms';
import { CryptoIcon, Icon } from '@suite-native/icons';
import { prepareNativeStyle, useNativeStyles } from '@trezor/styles';
import { nativeSpacings } from '@trezor/theme';
import { getNetworkSymbolColorBold } from '@suite-native/theme';

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

const styles = StyleSheet.create({
button: {
height: 36,
padding: nativeSpacings.sp4, // TODO 5?
paddingRight: nativeSpacings.sp8, // TODO 12?
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 NetworkButton = ({ symbol, onPress, caret }: AssetButtonProps) => {
const { DisplaySymbolFormatter } = useFormatters();
const { applyStyle } = useNativeStyles();
const symbolColor = getNetworkSymbolColorBold(symbol);

return (
<LinearGradient
colors={[hexToRgba(symbolColor, 0.3), hexToRgba(symbolColor, 0.01)]}
style={applyStyle(gradientBackgroundStyle)}
start={{ x: 0, y: 0.5 }}
end={{ x: 1, y: 0.5 }}
>
<AnimatedPressable onPress={onPress} style={styles.button}>
<CryptoIcon symbol={symbol} size="small" />
<Text color="textSubdued" variant="callout">
<DisplaySymbolFormatter value={symbol} areAmountUnitsEnabled={false} />
</Text>
{caret && <Icon name="caretDown" color="textSubdued" size="medium" />}
</AnimatedPressable>
</LinearGradient>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { NetworkSymbol } from '@suite-common/wallet-config/libDev/src';
import { BottomSheet, Button, SearchInput, VStack } from '@suite-native/atoms';

import { PopularNetworks } from './PopularNetworks';

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

export const NetworksSheet = ({ isVisible, onClose, onNetworkSelect }: NetworksSheetProps) => {
const onNetworkSelectCallback = (symbol: NetworkSymbol) => {
onNetworkSelect(symbol);
onClose();
};

return (
<BottomSheet
isVisible={isVisible}
onClose={onClose}
title="Tokens"
isCloseDisplayed={false}
>
<VStack spacing="sp16">
<SearchInput onChange={() => {}} />

<PopularNetworks
symbols={['btc', 'eth', 'sol', 'base']}
onNetworkSelect={onNetworkSelectCallback}
/>
<Button colorScheme="tertiaryElevation0" onPress={onClose}>
Close
</Button>
</VStack>
</BottomSheet>
);
};
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 SelectNetworkButtonProps = {
onPress: () => void;
selectedNetwork: NetworkSymbol | undefined;
};

export const SelectNetworkButton = ({ onPress, selectedNetwork }: SelectNetworkButtonProps) => {
const { iconColor } = buttonSchemeToColorsMap.primary;

if (selectedNetwork) {
return <NetworkButton symbol={selectedNetwork} 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 { SelectNetworkButton } from '../SelectNetworkButton';

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

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

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

expect(queryByText('Select coin')).toBeNull();
expect(queryByText('ADA')).toBeDefined();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { renderHook, act } from '@suite-native/test-utils';

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

describe('useTokensSheetControls', () => {
describe('isTokensSheetVisible', () => {
it('should be false by default', () => {
const { result } = renderHook(() => useTokensSheetControls());

expect(result.current.isTokensSheetVisible).toBe(false);
});

it('should be true after showTokensSheet call', () => {
const { result } = renderHook(() => useTokensSheetControls());

act(() => {
result.current.showTokensSheet();
});

expect(result.current.isTokensSheetVisible).toBe(true);
});

it('should be false after hideTokensSheet call', () => {
const { result } = renderHook(() => useTokensSheetControls());

act(() => {
result.current.showTokensSheet();
result.current.hideTokensSheet();
});

expect(result.current.isTokensSheetVisible).toBe(false);
});
});
});
Loading

0 comments on commit c16db60

Please sign in to comment.